288 lines
11 KiB
JavaScript
288 lines
11 KiB
JavaScript
// Standalone test for Stage 2 of the rich-text migration:
|
|
// - native document model (Tiptap/ProseMirror JSON)
|
|
// - Markdown -> native conversion (headings, bold, italic, strike, code,
|
|
// lists, checklists, tables, images, links, wikilinks, callouts,
|
|
// highlight, blockquote, hr)
|
|
// - native persistence + schema versioning
|
|
// - migration + external recovery backup
|
|
// - idempotency (original .md untouched)
|
|
// Builds src/main/migration.ts with esbuild (no Electron needed).
|
|
// Run with: node tests/run-migration-test.mjs
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { bundleForTest } from "./helpers/bundle.mjs";
|
|
|
|
const { mod, cleanupBundle } = await bundleForTest({
|
|
name: "folio-migration",
|
|
entrySource: `
|
|
export { migrateChapterToNative, migrateBookToNative, isNativeChapter } from "./src/main/migration.ts";
|
|
export { createBook, loadBook, saveMeta } from "./src/main/project.ts";
|
|
export { setChapterContent, getChapterContent } from "./src/main/chapters.ts";
|
|
export { markdownToNative } from "./src/main/markdown-to-native.ts";
|
|
export { parseChapter, serializeChapter, migrateDoc, getChapterDoc, nativeChapterFile, CURRENT_DOC_SCHEMA_VERSION, isValidDoc } from "./src/main/native-doc.ts";
|
|
export { getRecoveryRoot, _resetSessionBackupGuard } from "./src/main/backup.ts";
|
|
`,
|
|
});
|
|
const {
|
|
createBook,
|
|
setChapterContent,
|
|
getChapterContent,
|
|
loadBook,
|
|
saveMeta,
|
|
migrateChapterToNative,
|
|
migrateBookToNative,
|
|
isNativeChapter,
|
|
getChapterDoc,
|
|
markdownToNative,
|
|
parseChapter,
|
|
serializeChapter,
|
|
migrateDoc,
|
|
CURRENT_DOC_SCHEMA_VERSION,
|
|
getRecoveryRoot,
|
|
_resetSessionBackupGuard,
|
|
} = 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 findMark(node, type, acc = []) {
|
|
for (const m of node.marks ?? []) if (m.type === type) acc.push(m);
|
|
for (const c of node.content ?? []) findMark(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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Direct Markdown -> native conversion
|
|
// ---------------------------------------------------------------------------
|
|
const md = `# Title
|
|
|
|
A paragraph with **bold**, *italic*, ~~strike~~, \`code\` and ==highlight==.
|
|
|
|
<u>underline</u> and <span style="color:red">red</span> and <span style="background-color:#ff0">yellow bg</span>.
|
|
|
|
> [!note] Callout title
|
|
> Callout body text.
|
|
|
|
> A plain blockquote.
|
|
|
|
- bullet one
|
|
- bullet two
|
|
|
|
1. first
|
|
2. second
|
|
|
|
- [ ] todo
|
|
- [x] done
|
|
|
|
| Name | Age |
|
|
| ---- | --- |
|
|
| Ada | 36 |
|
|
|
|

|
|
|
|
A [link](https://example.com) and a [[Other Chapter]] wikilink.
|
|
|
|
\`\`\`js
|
|
const x = 1;
|
|
\`\`\`
|
|
|
|
---
|
|
|
|
Final paragraph.
|
|
`;
|
|
|
|
const doc = markdownToNative(md, {
|
|
targets: [{ id: "other-chapter-1", title: "Other Chapter" }],
|
|
});
|
|
|
|
ok(doc.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "converted doc has current schema version");
|
|
ok(doc.doc.type === "doc", "doc root is type 'doc'");
|
|
ok(findNodes(doc.doc, "heading").length === 1, "one heading converted");
|
|
ok(findNodes(doc.doc, "heading")[0]?.attrs?.level === 1, "heading is level 1");
|
|
ok(findNodes(doc.doc, "paragraph").length >= 5, "paragraphs converted");
|
|
ok(findMark(doc.doc, "bold").length === 1, "bold mark present");
|
|
ok(findMark(doc.doc, "italic").length === 1, "italic mark present");
|
|
ok(findMark(doc.doc, "strike").length === 1, "strike mark present");
|
|
ok(findMark(doc.doc, "code").length === 1, "inline code mark present");
|
|
ok(findMark(doc.doc, "highlight").length >= 2, "highlight marks present (== and bg span)");
|
|
ok(findMark(doc.doc, "textColor").length === 1, "textColor mark present");
|
|
ok(findMark(doc.doc, "underline").length === 1, "underline mark present");
|
|
ok(findNodes(doc.doc, "blockquote").length === 2, "two blockquotes (callout + quote)");
|
|
ok(
|
|
findNodes(doc.doc, "blockquote").some((b) => b.attrs?.callout === "note"),
|
|
"callout preserved as blockquote with callout type"
|
|
);
|
|
ok(allText(doc.doc).includes("Callout title"), "callout title preserved");
|
|
ok(findNodes(doc.doc, "bulletList").length === 1, "bullet list converted");
|
|
ok(findNodes(doc.doc, "orderedList").length === 1, "ordered list converted");
|
|
ok(findNodes(doc.doc, "taskList").length === 1, "checklist converted to taskList");
|
|
const checked = findNodes(doc.doc, "taskItem").filter((n) => n.attrs?.checked === true);
|
|
ok(checked.length === 1, "exactly one checklist item marked checked");
|
|
ok(findNodes(doc.doc, "table").length === 1, "table converted");
|
|
ok(findNodes(doc.doc, "tableRow").length === 2, "table has two rows");
|
|
ok(findNodes(doc.doc, "tableHeader").length === 2, "table header cells converted");
|
|
ok(findNodes(doc.doc, "image").length === 1, "image converted");
|
|
ok(
|
|
findNodes(doc.doc, "image")[0]?.attrs?.src === "https://example.com/cat.png",
|
|
"external image src kept as-is"
|
|
);
|
|
const links = findMark(doc.doc, "link");
|
|
ok(links.length === 2, "two links (standard + wikilink)");
|
|
ok(
|
|
links.some((l) => l.attrs?.href === "https://example.com"),
|
|
"standard link href preserved"
|
|
);
|
|
ok(
|
|
links.some((l) => l.attrs?.href === "folio:chapter/other-chapter-1"),
|
|
"wikilink resolved to folio:chapter/<id>"
|
|
);
|
|
ok(findNodes(doc.doc, "codeBlock").length === 1, "fenced code block converted");
|
|
ok(
|
|
findNodes(doc.doc, "codeBlock")[0]?.attrs?.language === "js",
|
|
"code block language preserved"
|
|
);
|
|
ok(findNodes(doc.doc, "horizontalRule").length === 1, "horizontal rule converted");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Round-trip serialize / parse / migrate
|
|
// ---------------------------------------------------------------------------
|
|
const reparsed = parseChapter(serializeChapter(doc));
|
|
ok(reparsed.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "reparsed doc keeps schema version");
|
|
ok(reparsed.doc.type === "doc", "reparsed doc root intact");
|
|
ok(
|
|
JSON.stringify(reparsed.doc) === JSON.stringify(doc.doc),
|
|
"serialize -> parse is lossless"
|
|
);
|
|
|
|
const oldDoc = { schemaVersion: 0, doc: { type: "doc", content: [] } };
|
|
const migrated = migrateDoc(oldDoc);
|
|
ok(migrated.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "migrateDoc bumps old schema version");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. End-to-end migration of a real book (backup + persistence + idempotency)
|
|
// ---------------------------------------------------------------------------
|
|
const book = fs.mkdtempSync(path.join(os.tmpdir(), "folio-test-"));
|
|
_resetSessionBackupGuard();
|
|
const meta = createBook(book, "Migration Book");
|
|
const c1 = "chapter-one-1111";
|
|
const c2 = "chapter-two-2222";
|
|
meta.chapters[c1] = { id: c1, title: "Chapter One", file: `chapters/${c1}.md` };
|
|
meta.chapters[c2] = { id: c2, title: "Chapter Two", file: `chapters/${c2}.md` };
|
|
meta.chapterOrder = [c1, c2];
|
|
saveMeta(book, meta);
|
|
fs.mkdirSync(path.join(book, "chapters"), { recursive: true });
|
|
const md1 = "# One\n\nHello **world**.";
|
|
const md2 = "## Two\n\n- a\n- b\n\n";
|
|
fs.writeFileSync(path.join(book, `chapters/${c1}.md`), md1, "utf-8");
|
|
fs.writeFileSync(path.join(book, `chapters/${c2}.md`), md2, "utf-8");
|
|
|
|
const before1 = fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8");
|
|
const before2 = fs.readFileSync(path.join(book, `chapters/${c2}.md`), "utf-8");
|
|
|
|
const results = migrateBookToNative(book);
|
|
ok(
|
|
results.every((r) => r.status === "migrated"),
|
|
"both chapters migrated"
|
|
);
|
|
ok(fs.existsSync(path.join(book, `chapters/${c1}.json`)), "chapter json written");
|
|
ok(fs.existsSync(path.join(book, `chapters/${c2}.json`)), "chapter json written (2)");
|
|
ok(
|
|
fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8") === before1,
|
|
"ORIGINAL .md left untouched (1)"
|
|
);
|
|
ok(
|
|
fs.readFileSync(path.join(book, `chapters/${c2}.md`), "utf-8") === before2,
|
|
"ORIGINAL .md left untouched (2)"
|
|
);
|
|
|
|
const metaAfter = loadBook(book);
|
|
ok(metaAfter.docFormat === "folio-doc", "book marked as folio-doc");
|
|
ok(metaAfter.docSchemaVersion === CURRENT_DOC_SCHEMA_VERSION, "book has doc schema version");
|
|
ok(metaAfter.chapters[c1].file.endsWith(".json"), "chapter entry repointed to .json");
|
|
|
|
ok(isNativeChapter(book, c1), "isNativeChapter true after migration");
|
|
const readBack = getChapterDoc(book, c1);
|
|
ok(readBack.doc.type === "doc", "migrated chapter reads back as native doc");
|
|
ok(
|
|
findMark(readBack.doc, "bold").length === 1,
|
|
"migrated chapter preserved bold"
|
|
);
|
|
|
|
// Recovery backup exists under ~/Folio-Recovery and contains folio.json
|
|
const bookName = path.basename(book);
|
|
const backups = fs
|
|
.readdirSync(getRecoveryRoot())
|
|
.filter((d) => d.startsWith(bookName) && fs.statSync(path.join(getRecoveryRoot(), d)).isDirectory());
|
|
ok(backups.length >= 1, "recovery backup created under ~/Folio-Recovery");
|
|
const backupDir = path.join(getRecoveryRoot(), backups[0]);
|
|
ok(fs.existsSync(path.join(backupDir, "folio.json")), "backup contains folio.json");
|
|
ok(
|
|
fs.existsSync(path.join(backupDir, "chapters", `${c1}.md`)),
|
|
"backup preserves original .md"
|
|
);
|
|
|
|
// Idempotency: re-migrating does not rewrite .md and reports already-native
|
|
const results2 = migrateBookToNative(book);
|
|
ok(
|
|
results2.every((r) => r.status === "already-native"),
|
|
"re-migration is idempotent (already-native)"
|
|
);
|
|
ok(
|
|
fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8") === before1,
|
|
"ORIGINAL .md still untouched after re-migration"
|
|
);
|
|
|
|
// Image asset copy: a local image is copied into assets/ and rewritten.
|
|
const c3 = "chapter-three-3333";
|
|
const meta3 = loadBook(book);
|
|
meta3.chapters[c3] = { id: c3, title: "Chapter Three", file: `chapters/${c3}.md` };
|
|
meta3.chapterOrder.push(c3);
|
|
saveMeta(book, meta3);
|
|
fs.writeFileSync(path.join(book, `chapters/${c3}.md`), "", "utf-8");
|
|
fs.mkdirSync(path.join(book, "img"), { recursive: true });
|
|
fs.writeFileSync(path.join(book, "img", "local.png"), "PNGDATA", "utf-8");
|
|
_resetSessionBackupGuard();
|
|
const r3 = migrateChapterToNative(book, c3);
|
|
ok(r3.status === "migrated", "third chapter (local image) migrated");
|
|
const doc3 = getChapterDoc(book, c3);
|
|
const img3 = findNodes(doc3.doc, "image")[0];
|
|
ok(
|
|
img3?.attrs?.src?.startsWith("assets/"),
|
|
"local image reference rewritten to assets/"
|
|
);
|
|
ok(
|
|
fs.existsSync(path.join(book, img3.attrs.src)),
|
|
"local image copied into assets/"
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cleanup
|
|
// ---------------------------------------------------------------------------
|
|
cleanupBundle();
|
|
fs.rmSync(book, { recursive: true, force: true });
|
|
for (const d of backups) {
|
|
fs.rmSync(path.join(getRecoveryRoot(), d), { recursive: true, force: true });
|
|
}
|
|
|
|
console.log(
|
|
"\nMigration tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")
|
|
);
|
|
process.exit(failures === 0 ? 0 : 1);
|