Folio/tests/run-editor-test.mjs

234 lines
8.7 KiB
JavaScript

// Runner for the Tiptap editor test. Sets up a jsdom DOM, then dynamically
// imports the bundled editor module so Tiptap sees a browser-like environment.
import fs from "fs";
import os from "os";
import path from "path";
import { JSDOM } from "jsdom";
import { bundleForTest } from "./helpers/bundle.mjs";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
const { window } = dom;
const g = globalThis;
g.window = window;
g.document = window.document;
g.HTMLElement = window.HTMLElement;
g.Element = window.Element;
g.Node = window.Node;
g.MutationObserver = window.MutationObserver;
g.getComputedStyle = window.getComputedStyle.bind(window);
g.DOMParser = window.DOMParser;
g.requestAnimationFrame = (cb) => setTimeout(cb, 0);
g.cancelAnimationFrame = (id) => clearTimeout(id);
const { mod, cleanupBundle } = await bundleForTest({
name: "folio-editor",
entryPoints: ["tests/.folio-editor-test-entry.ts"],
});
const {
createTiptapEditor,
runCommand,
getEditorDoc,
setEditorDoc,
setChapterDoc,
getChapterDoc,
parseChapter,
createBook,
saveMeta,
markdownToNative,
} = mod;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function findNodes(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
for (const c of node.content ?? []) findNodes(c, type, acc);
return acc;
}
function findMarks(node, type, acc = []) {
for (const m of node.marks ?? []) if (m.type === type) acc.push(m);
for (const c of node.content ?? []) findMarks(c, type, acc);
return acc;
}
function allText(node, acc = "") {
if (node.text) acc += node.text;
for (const c of node.content ?? []) acc = allText(c, acc);
return acc;
}
function makeEditor() {
const host = window.document.createElement("div");
window.document.body.appendChild(host);
return createTiptapEditor(host, {});
}
const book = fs.mkdtempSync(path.join(os.tmpdir(), "folio-ed-"));
const meta = createBook(book, "Editor Book");
const id = "chap-1";
meta.chapters[id] = { id, title: "Chapter One", file: `chapters/${id}.json` };
meta.chapterOrder = [id];
saveMeta(book, meta);
// --- editor command -> state (each feature on a fresh editor) ---
function editorDocAfter(commands) {
const ed = makeEditor();
for (const c of commands) {
if (typeof c === "function") c(ed);
else runCommand(ed, c.cmd, c.payload);
}
const d = getEditorDoc(ed);
return { ed, doc: d.doc };
}
let r;
r = editorDocAfter([{ cmd: "h2" }, (ed) => ed.commands.insertContent("Title")]);
ok(r.doc.type === "doc", "editor state is a ProseMirror doc");
ok(findNodes(r.doc, "heading").some((h) => h.attrs?.level === 2), "heading level 2 present");
ok(allText(r.doc).includes("Title"), "inserted heading text present");
r = editorDocAfter([{ cmd: "bullet" }, (ed) => ed.commands.insertContent("item one")]);
ok(findNodes(r.doc, "bulletList").length === 1, "bullet list present");
ok(allText(r.doc).includes("item one"), "bullet list text present");
r = editorDocAfter([
{ cmd: "bold" },
(ed) => ed.commands.insertContent("bolded"),
{ cmd: "color", payload: { color: "#ff0000" } },
(ed) => ed.commands.insertContent("red"),
]);
ok(findMarks(r.doc, "bold").length >= 1, "bold mark present");
ok(findMarks(r.doc, "textColor").some((m) => m.attrs?.color === "#ff0000"), "text color mark present");
r = editorDocAfter([
{ cmd: "table", payload: { rows: 2, cols: 2 } },
{ cmd: "image", payload: { src: "assets/x.png", alt: "x" } },
]);
ok(findNodes(r.doc, "table").length === 1, "table present");
ok(findNodes(r.doc, "image").length === 1, "image present");
ok(findNodes(r.doc, "image")[0]?.attrs?.src === "assets/x.png", "image src preserved");
// A richer document combining several features in one editor. Each block-level
// feature is placed in its own block so toggling a list does not consume the
// heading (which is the correct ProseMirror behavior).
const editor = makeEditor();
runCommand(editor, "h2");
editor.commands.insertContent("Hello world");
editor.commands.insertContent({ type: "paragraph" });
runCommand(editor, "bold");
editor.commands.insertContent("bolded");
runCommand(editor, "color", { color: "#ff0000" });
editor.commands.insertContent(" red");
editor.commands.insertContent({ type: "paragraph" });
runCommand(editor, "bullet");
editor.commands.insertContent("list item");
runCommand(editor, "table", { rows: 2, cols: 2 });
runCommand(editor, "image", { src: "assets/x.png", alt: "x" });
const doc = getEditorDoc(editor);
ok(allText(doc.doc).includes("Hello world"), "combined document has text");
ok(findNodes(doc.doc, "heading").some((h) => h.attrs?.level === 2), "combined: heading present");
ok(findMarks(doc.doc, "bold").length >= 1, "combined: bold present");
ok(findNodes(doc.doc, "bulletList").length >= 1, "combined: bullet list present");
ok(findMarks(doc.doc, "textColor").some((m) => m.attrs?.color === "#ff0000"), "combined: color present");
ok(findNodes(doc.doc, "table").length === 1, "combined: table present");
ok(findNodes(doc.doc, "image").length === 1, "combined: image present");
// --- state -> native persistence ---
setChapterDoc(book, id, doc);
const reloaded = getChapterDoc(book, id);
ok(reloaded.doc.type === "doc", "persisted chapter reads back as doc");
ok(
JSON.stringify(reloaded.doc) === JSON.stringify(doc.doc),
"reload is byte-identical to editor state"
);
// --- reload -> editor (round trip through a fresh editor) ---
const editor2 = makeEditor();
setEditorDoc(editor2, reloaded.doc);
const doc2 = getEditorDoc(editor2);
ok(
JSON.stringify(doc2.doc) === JSON.stringify(doc.doc),
"second editor reproduces same state"
);
// --- Markdown-migrated document loads into the editor ---
const migrated = markdownToNative("# Title\n\nSome **strong** text and a [link](https://e.com).");
const editor3 = makeEditor();
setEditorDoc(editor3, migrated.doc);
const doc3 = getEditorDoc(editor3);
ok(findNodes(doc3.doc, "heading").some((h) => h.attrs?.level === 1), "migrated heading loads");
ok(findMarks(doc3.doc, "bold").length === 1, "migrated bold loads");
ok(findMarks(doc3.doc, "link").some((m) => m.attrs?.href === "https://e.com"), "migrated link loads");
ok(allText(doc3.doc).includes("Title"), "migrated text loads");
// --- document containing a hard break loads (snake_case hard_break node) ---
// Regression: StarterKit registers HardBreak as `hardBreak`; a doc with a
// `hard_break` node (as produced by markdown-to-native and the export pipeline)
// must load and render its text rather than throwing and leaving the editor empty.
const withBreak = markdownToNative("Line one \nline two\n\nPara two");
const editor4 = makeEditor();
setEditorDoc(editor4, withBreak.doc);
const doc4 = getEditorDoc(editor4);
ok(editor4.getText().includes("Line one") && editor4.getText().includes("line two"),
"hard_break document renders its text (not empty)");
ok(findNodes(doc4.doc, "hard_break").length === 1, "hard_break node preserved on load");
// --- legacy camelCase hardBreak (from older editor saves) normalizes on read ---
const camel = {
schemaVersion: 1,
doc: {
type: "doc",
content: [
{ type: "paragraph", content: [
{ type: "text", text: "alpha" },
{ type: "hardBreak" },
{ type: "text", text: "beta" },
] },
],
},
};
const normalized = parseChapter(JSON.stringify(camel));
ok(findNodes(normalized.doc, "hard_break").length === 1, "parseChapter renames hardBreak -> hard_break");
const editor5 = makeEditor();
setEditorDoc(editor5, normalized.doc);
ok(editor5.getText().includes("alpha") && editor5.getText().includes("beta"),
"legacy camelCase hardBreak chapter loads (not empty)");
// --- text alignment via toolbar command ---
const alignEd = makeEditor();
setEditorDoc(alignEd, { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "align me" }] }] });
alignEd.commands.focus("end");
runCommand(alignEd, "align-center");
ok(
(alignEd.getJSON().content[0].attrs?.textAlign || "") === "center",
"align-center sets the paragraph textAlign"
);
// --- standalone Highlight button (no color) must apply a highlight, not be a no-op ---
const hlEd = makeEditor();
setEditorDoc(hlEd, { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "mark me" }] }] });
hlEd.commands.selectAll();
runCommand(hlEd, "highlight");
const hlMarks = hlEd.getJSON().content[0].content[0].marks || [];
ok(
hlMarks.some((m) => m.type === "highlight"),
"standalone Highlight button applies a default highlight to the selection"
);
cleanupBundle();
fs.rmSync(book, { recursive: true, force: true });
console.log(
"\nEditor tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")
);
process.exit(failures === 0 ? 0 : 1);