234 lines
8.7 KiB
JavaScript
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);
|