Folio/tests/run-migration-test.mjs

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 |
![cat](https://example.com/cat.png)
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![pic](https://example.com/p.png)";
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`), "![x](img/local.png)", "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);