/** * savePost 全量往返测试 —— 对仓库里**每一篇**真实文章跑一遍「读出来原样存回去」。 * * 为什么要有这个:frontmatter-roundtrip 只测 split/join 这两个纯函数, * 但真正会写坏文章的是 getPost → savePost 这条链路(中间的 content 剥了前导换行、 * 回写时又要还原,任何一处不对称都会在部分文章上产生脏 diff)。 * 所以这里必须用真文件、真篇数跑,不能只挑一篇(19/130 的写法差异就藏在里面)。 * * 测试期间会**真实改写文件**,但每篇测完都立刻按原字节还原; * 结尾再逐篇核对 sha256,任何一篇没还原都会让脚本失败并指名道姓。 * * 跑法:node test/save-roundtrip.mjs */ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { makePosts } from '../src/posts.mjs'; import { splitFrontMatter } from '../src/frontmatter.mjs'; const repoRoot = path.resolve(process.env.BLOG_ROOT || path.join(import.meta.dirname, '..', '..')); const contentDir = path.join(repoRoot, 'content', 'posts'); const trashDir = path.join(repoRoot, '.editor-tmp', 'trash'); const posts = makePosts({ contentDir, trashDir, repoRoot, defaultAuthor: '' }); const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); const fmBlockOf = (text) => { const { raw } = splitFrontMatter(text); if (raw == null) return null; return '---\n' + raw + '\n---'; }; const list = posts.listPosts({ perPage: 500 }); console.log('待测文章: ' + list.total + ' 篇 (仓库 ' + repoRoot + ')\n'); let pass = 0; let fail = 0; const failures = []; /** 记录每篇的原始字节,最后统一核对还原情况 */ const originals = new Map(); for (const item of list.posts) { // 用 id(目录名)而不是 slug —— 仓库里有 slug 撞名,按 slug 会拿到 409 或别的文章 const post = posts.getPost(item.id); if (!post || typeof post.filePath !== 'string') { fail++; failures.push(item.id + ' → getPost 拿不到 filePath'); continue; } const file = post.filePath; const before = fs.readFileSync(file); originals.set(file, before); const beforeHash = crypto.createHash('sha256').update(before).digest('hex'); let mark = '✓'; // ---- ① 原样回存:必须逐字节不变(这是最重要的一条)---- posts.savePost(item.id, { content: post.content, frontMatter: post.frontMatter }); const afterHash = sha(file); if (afterHash !== beforeHash) { mark = '✗'; failures.push(item.id + ' → 「原样回存」字节变了 ' + beforeHash.slice(0, 12) + ' ≠ ' + afterHash.slice(0, 12)); } // ---- ② 只改正文:front matter 区块必须逐字节不变 ---- const midText = fs.readFileSync(file, 'utf8'); const fmBefore = fmBlockOf(midText); posts.savePost(item.id, { content: post.content + '\n\n\n' }); const editedText = fs.readFileSync(file, 'utf8'); if (fmBefore !== fmBlockOf(editedText)) { mark = '✗'; failures.push(item.id + ' → 改正文时 front matter 被改动'); } if (!editedText.includes('')) { mark = '✗'; failures.push(item.id + ' → 正文没写进去'); } // ---- ③ 立刻还原成原字节 ---- fs.writeFileSync(file, before); if (mark === '✓') pass++; else fail++; if (mark === '✗') console.log(mark + ' ' + item.slug); } // ---- ④ 总核对:全仓库不允许残留任何被改动的文章 ---- console.log('\n--- 还原核对 ---'); let notRestored = 0; for (const [file, buf] of originals) { const now = fs.readFileSync(file); if (!now.equals(buf)) { notRestored++; console.log(' ✗ 未还原: ' + file); } } if (notRestored === 0) console.log(' ✓ ' + originals.size + ' 篇全部还原为原始字节'); console.log('\n================================'); console.log('通过 ' + pass + ' / 失败 ' + fail + ' 未还原 ' + notRestored); if (failures.length) { console.log('\n失败明细:'); for (const f of failures) console.log(' · ' + f); } process.exit(fail || notRestored ? 1 : 0);