feat(editor): 在线编辑文章(Worker 前端 + 轻量 docker 后端)
后端(新增 editor-api/,零 npm 依赖,只用 node 内置模块): - 只做文章相关:列表/读取/新建/保存/删除/图片上传/git 状态·发布·同步 - front matter 往返保真:未改动的块按字节照抄,CRLF/块标量/引号写法都不动 - 列表用目录名当 id,slug 撞名不再静默改错文件(返回 409 列候选) - 鉴权只有一条路:X-Editor-Token(只存在 Worker 侧,浏览器拿不到) - 端口只绑 127.0.0.1,由宿主机 nginx 反代出去 部署(新增 deploy/editor-api/bootstrap.sh): - 一条命令在新机器上完成 克隆仓库→写 .env→起容器→健康检查 - 状态全在两个目录(/srv/blog 仓库工作区 + /srv/editor-api 配置), 迁移 = 复制目录或在新机重跑本脚本,容器本身无状态 Cloudflare Worker 侧(blog-admin): - src/routes/editor.ts:鉴权 + 反代,浏览器只跟 Worker 说话 - 管理面板新增「文章编辑」:两栏布局 + 快捷插入面板(13 项短代码, 与 write-server 的 ShortcutPanel 一致)+ 底部 草稿/保存/发布 - 系统设置改为 schema 驱动的表单,且**以运行时实际生效的配置为准** (frontend_conf/captcha/moderator/ip_region/site_default + KV human_check), 修复「表单显示一套、评论系统跑另一套」的脱节问题 验证:tsc 0 错;front matter 往返 130/130;保存往返 130/130; API e2e 44/44;无头 Chrome UI e2e 14/14
This commit is contained in:
1 parent
48a2fd697f
commit
c217d20c30
24 files changed
+3597
-221
No files matched your search
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* editor-api 端到端回归测试(对真实仓库跑,测完自动还原)。
|
||||
*
|
||||
* node test/api-e2e.mjs [baseUrl] [token]
|
||||
*
|
||||
* 重点验证「不写坏老文章」这条底线:
|
||||
* ① 只改正文 → front matter 区块必须**逐字节不变**(含 pid / ai_comment 等冷门键)
|
||||
* ② 原样回存 → 整个文件必须**逐字节不变**
|
||||
* ③ 改 front matter 字段 → 其余字段必须原样保留
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const BASE = process.argv[2] || 'http://127.0.0.1:8017';
|
||||
const TOKEN = process.argv[3] || 'devtoken';
|
||||
const ROOT = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
const TARGET_SLUG = '20251121';
|
||||
const TARGET_FILE = path.join(ROOT, 'content/posts/2025/2025-11-21-除草日记/index.md');
|
||||
// ★ API 的定位键是**目录名**(全仓唯一),不是 slug —— 仓库里有 5 组 slug 撞名,
|
||||
// 按 slug 取会静默地操作到另一篇文章上。这里刻意用带中文的目录名走一遍,
|
||||
// 顺带验证 URL 编码链路。
|
||||
const TARGET_ID = path.basename(path.dirname(TARGET_FILE));
|
||||
const P = (id) => '/posts/' + encodeURIComponent(id);
|
||||
|
||||
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const read = (p) => fs.readFileSync(p, 'utf8');
|
||||
|
||||
let pass = 0;
|
||||
const fails = [];
|
||||
function check(name, cond, extra = '') {
|
||||
if (cond) {
|
||||
pass++;
|
||||
console.log(' ✅ ' + name);
|
||||
} else {
|
||||
fails.push(name + (extra ? ' → ' + extra : ''));
|
||||
console.log(' ❌ ' + name + (extra ? ' → ' + extra : ''));
|
||||
}
|
||||
}
|
||||
|
||||
async function call(method, p, body, raw) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
'X-Editor-Token': TOKEN,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
const original = read(TARGET_FILE);
|
||||
const originalSha = sha(TARGET_FILE);
|
||||
|
||||
console.log('\n== ① 鉴权 ==');
|
||||
{
|
||||
const noToken = await fetch(BASE + '/posts');
|
||||
check('无令牌 → 401', noToken.status === 401, '实际 ' + noToken.status);
|
||||
const health = await fetch(BASE + '/health');
|
||||
check('/health 免鉴权', health.status === 200, '实际 ' + health.status);
|
||||
const bad = await call('GET', '/posts');
|
||||
check('带令牌 → 200', bad.status === 200, '实际 ' + bad.status);
|
||||
}
|
||||
|
||||
console.log('\n== ② 读取 ==');
|
||||
let post;
|
||||
{
|
||||
const r = await call('GET', P(TARGET_ID));
|
||||
post = r.data;
|
||||
check('能用目录名(含中文)定位', r.status === 200 && post.slug === TARGET_SLUG, r.status + ' / ' + post?.slug);
|
||||
check('返回 id = 目录名', post.id === TARGET_ID, String(post.id));
|
||||
check('front matter 解析成功', post.frontMatter && !post.frontMatterError, String(post.frontMatterError || ''));
|
||||
check('冷门键 pid 已解析', post.frontMatter && post.frontMatter.pid === 133, JSON.stringify(post.frontMatter?.pid));
|
||||
check('title 正确', post.title === '除草日记', String(post.title));
|
||||
}
|
||||
|
||||
console.log('\n== ③ 原样回存 → 必须逐字节不变 ==');
|
||||
{
|
||||
const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
|
||||
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
|
||||
check('文件 sha256 未变', sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16) + ' vs ' + originalSha.slice(0, 16));
|
||||
}
|
||||
|
||||
console.log('\n== ④ 只改正文 → front matter 区块必须逐字节不变 ==');
|
||||
{
|
||||
const body = post.content + '\n\n(测试追加的一行,稍后还原)\n';
|
||||
const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: body });
|
||||
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
|
||||
|
||||
const now = read(TARGET_FILE);
|
||||
const fmOf = (t) => t.replace(/\r\n/g, '\n').split('\n').slice(0, t.split('\n').findIndex((l, i) => i > 0 && l.trim() === '---') + 1).join('\n');
|
||||
check('front matter 区块逐字节不变', fmOf(now) === fmOf(original));
|
||||
check('正文确实写进去了', now.includes('测试追加的一行'));
|
||||
check('冷门键 ai_comment 仍在', now.includes('ai_comment:'));
|
||||
check('pid 仍在', now.includes('pid: 133'));
|
||||
|
||||
// 还原
|
||||
const back = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
|
||||
check('还原成功', back.status === 200 && sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16));
|
||||
}
|
||||
|
||||
console.log('\n== ⑤ 改 front matter 字段 → 其余字段保留 ==');
|
||||
{
|
||||
const fm = { ...post.frontMatter, title: '除草日记(测试改名)' };
|
||||
const r = await call('PUT', P(TARGET_ID), { frontMatter: fm, content: post.content });
|
||||
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
|
||||
|
||||
const after = (await call('GET', P(TARGET_ID))).data;
|
||||
check('title 已更新', after.title === '除草日记(测试改名)', String(after.title));
|
||||
check('pid 未丢', after.frontMatter.pid === 133, JSON.stringify(after.frontMatter.pid));
|
||||
check('ai_comment 未丢', 'ai_comment' in after.frontMatter);
|
||||
check('ai_comment 值未变(含结尾换行)', after.frontMatter.ai_comment === post.frontMatter.ai_comment);
|
||||
check('categories 未丢', Array.isArray(after.frontMatter.categories) && after.frontMatter.categories.length === 1);
|
||||
|
||||
// 还原。注意:一旦真的改过 front matter,整块会被按规范重排
|
||||
// (例如 >- 折叠块变成 | 字面块),所以这里校验的是**值**而不是字节。
|
||||
// 「字节不变」的保证只适用于「没碰 front matter」的场景,见 ③ / ④。
|
||||
await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
|
||||
const back = (await call('GET', P(TARGET_ID))).data;
|
||||
check('还原后 front matter 值与原值一致',
|
||||
JSON.stringify(back.frontMatter) === JSON.stringify(post.frontMatter),
|
||||
JSON.stringify(back.frontMatter).slice(0, 160));
|
||||
check('还原后正文与原文一致', back.content === post.content);
|
||||
check('还原后换行风格未变(CRLF/LF 保持)', (read(TARGET_FILE).includes('\r\n')) === original.includes('\r\n'));
|
||||
}
|
||||
|
||||
console.log('\n== ⑥ 新建 → 删除(走回收站)==');
|
||||
{
|
||||
const slug = 'e2e-tmp-' + Date.now().toString(36);
|
||||
const created = await call('POST', '/posts', {
|
||||
frontMatter: { title: 'E2E 临时文章', slug, date: '2026-10-04', categories: ['测试'], tags: ['a', 'b'], draft: true },
|
||||
content: '# 标题\n\n正文。\n',
|
||||
});
|
||||
check('新建成功', created.status === 200, JSON.stringify(created.data).slice(0, 160));
|
||||
|
||||
const dir = created.data?.dirPath;
|
||||
check('目录已创建', !!dir && fs.existsSync(path.join(dir, 'index.md')), String(dir));
|
||||
|
||||
const got = await call('GET', '/posts/' + slug);
|
||||
check('能读回', got.status === 200 && got.data.title === 'E2E 临时文章');
|
||||
|
||||
const dup = await call('POST', '/posts', { frontMatter: { title: '重名', slug }, content: '' });
|
||||
check('重复 slug 被拒绝(400/409)', dup.status === 400 || dup.status === 409, '实际 ' + dup.status);
|
||||
|
||||
const del = await call('DELETE', '/posts/' + slug);
|
||||
check('删除成功', del.status === 200, JSON.stringify(del.data).slice(0, 120));
|
||||
check('目录已移走', !!dir && !fs.existsSync(dir));
|
||||
if (del.data?.movedTo && fs.existsSync(del.data.movedTo)) fs.rmSync(del.data.movedTo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('\n== ⑦ 图片上传 ==');
|
||||
{
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const res = await fetch(BASE + '/upload?name=t.png&key=' + encodeURIComponent(TARGET_ID), {
|
||||
method: 'POST',
|
||||
headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'image/png' },
|
||||
body: png,
|
||||
});
|
||||
const data = await res.json();
|
||||
check('上传成功', res.status === 200 && data.ok, JSON.stringify(data).slice(0, 140));
|
||||
if (data.fileName) {
|
||||
const f = path.join(path.dirname(TARGET_FILE), data.fileName);
|
||||
check('文件落盘', fs.existsSync(f));
|
||||
check('返回裸文件名(仓库现行写法)', data.url === data.fileName, String(data.url));
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
}
|
||||
const bad = await fetch(BASE + '/upload?name=x.txt', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'text/plain' },
|
||||
body: 'hi',
|
||||
});
|
||||
check('非图片被拒绝', bad.status === 400, String(bad.status));
|
||||
}
|
||||
|
||||
console.log('\n== ⑧ git 状态 ==');
|
||||
{
|
||||
const r = await call('GET', '/git/status');
|
||||
check('能取到状态', r.status === 200 && typeof r.data.branch === 'string', JSON.stringify(r.data).slice(0, 140));
|
||||
console.log(' 分支 ' + r.data.branch + ' | 改动 ' + r.data.changed + ' 个文件 | 最近提交 ' + String(r.data.lastCommit).slice(0, 60));
|
||||
}
|
||||
|
||||
console.log('\n== ⑨ slug 撞名 → 必须拒绝,不能猜 ==');
|
||||
{
|
||||
// 仓库真实存在:2021-09-01-Twitter… 与 2021-08-31-无悔 都用 slug 20210901
|
||||
const r = await call('GET', '/posts/20210901');
|
||||
check('撞名 slug → 409(而不是随便挑一篇)', r.status === 409, '实际 ' + r.status);
|
||||
check('错误信息里点出候选篇目', String(r.data?.error || '').includes('无悔'), String(r.data?.error || '').slice(0, 120));
|
||||
check('单篇 slug 仍可定位', (await call('GET', '/posts/20251121')).status === 200);
|
||||
check('不存在的 id → 404', (await call('GET', '/posts/__不存在__')).status === 404);
|
||||
}
|
||||
|
||||
console.log('\n== ⑩ front matter 后没有空行的文章 → 不能平白多插空行 ==');
|
||||
{
|
||||
// 语料里 111 篇有空行、19 篇没有;这类写法差异最容易在回存时产生脏 diff
|
||||
const list = (await call('GET', '/posts?perPage=200')).data.posts;
|
||||
let tested = 0;
|
||||
for (const item of list) {
|
||||
const r = await call('GET', P(item.id));
|
||||
if (r.status !== 200 || !r.data?.filePath) continue;
|
||||
const file = r.data.filePath;
|
||||
const before = read(file);
|
||||
// 只挑「--- 之后紧接正文」的那种
|
||||
if (/\n---\r?\n[^\r\n]/.test(before) === false) continue;
|
||||
|
||||
await call('PUT', P(item.id), { frontMatter: r.data.frontMatter, content: r.data.content });
|
||||
check('无空行文章原样回存字节不变:' + item.id,
|
||||
sha(file) === crypto.createHash('sha256').update(before).digest('hex'));
|
||||
fs.writeFileSync(file, before);
|
||||
if (++tested >= 3) break;
|
||||
}
|
||||
if (!tested) console.log(' (语料里没找到这种写法,跳过)');
|
||||
}
|
||||
|
||||
// 收尾:确保目标文件恢复原样
|
||||
if (sha(TARGET_FILE) !== originalSha) {
|
||||
fs.writeFileSync(TARGET_FILE, original);
|
||||
check('测试后强制还原目标文件', true);
|
||||
}
|
||||
|
||||
console.log('\n──────────────────────────────');
|
||||
console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项');
|
||||
if (fails.length) {
|
||||
console.log('失败明细:');
|
||||
for (const f of fails) console.log(' · ' + f);
|
||||
}
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* front matter 解析器回归测试:拿仓库里全部真实文章跑无损往返。
|
||||
*
|
||||
* node test/frontmatter-roundtrip.mjs [contentDir]
|
||||
*
|
||||
* 断言三件事:
|
||||
* 1. split → join 能**逐字节**还原原文件(说明切分没吃掉任何字符)
|
||||
* 2. parse → stringify → parse 后对象与首次解析**深相等**(说明丢了不信息)
|
||||
* 3. 记录所有解析失败的样本,便于判断是否需要补语法
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { splitFrontMatter, joinFrontMatter, parse, stringify, deepEqual } from '../src/frontmatter.mjs';
|
||||
|
||||
const root = process.argv[2] || path.resolve(import.meta.dirname, '../../content/posts');
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const p = path.join(dir, name);
|
||||
const st = fs.statSync(p);
|
||||
if (st.isDirectory()) walk(p, out);
|
||||
else if (name.endsWith('.md')) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = walk(root);
|
||||
let ok = 0;
|
||||
const byteFail = [];
|
||||
const parseFail = [];
|
||||
const roundFail = [];
|
||||
|
||||
for (const f of files) {
|
||||
const text = fs.readFileSync(f, 'utf8');
|
||||
const rel = path.relative(root, f);
|
||||
|
||||
const { raw, body, eol } = splitFrontMatter(text);
|
||||
if (raw == null) {
|
||||
parseFail.push([rel, '没有 front matter']);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. 逐字节还原(含原始换行风格)
|
||||
if (joinFrontMatter(raw, body, eol) !== text) byteFail.push(rel);
|
||||
|
||||
// 2. 解析 + 往返
|
||||
let first;
|
||||
try {
|
||||
first = parse(raw);
|
||||
} catch (e) {
|
||||
parseFail.push([rel, e.message]);
|
||||
continue;
|
||||
}
|
||||
|
||||
let second;
|
||||
try {
|
||||
second = parse(stringify(first));
|
||||
} catch (e) {
|
||||
roundFail.push([rel, 'stringify/parse 失败: ' + e.message]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!deepEqual(first, second)) {
|
||||
const diff = Object.keys(first).concat(Object.keys(second)).filter(
|
||||
(k, i, a) => a.indexOf(k) === i && !deepEqual(first[k], second[k]),
|
||||
);
|
||||
roundFail.push([rel, '字段不一致: ' + diff.join(', ')]);
|
||||
continue;
|
||||
}
|
||||
|
||||
ok++;
|
||||
}
|
||||
|
||||
const total = files.length;
|
||||
console.log('样本总数 :', total);
|
||||
console.log('解析 + 往返通过 :', ok);
|
||||
console.log('切分不还原 :', byteFail.length);
|
||||
console.log('解析失败 :', parseFail.length);
|
||||
console.log('往返不一致 :', roundFail.length);
|
||||
|
||||
const show = (title, arr) => {
|
||||
if (!arr.length) return;
|
||||
console.log('\n--- ' + title + ' ---');
|
||||
for (const [f, why] of arr.slice(0, 12)) console.log(' ' + f + ' || ' + why);
|
||||
if (arr.length > 12) console.log(' … 另有 ' + (arr.length - 12) + ' 条');
|
||||
};
|
||||
|
||||
show('切分不还原(必须为 0)', byteFail.map((f) => [f, 'join != 原文']));
|
||||
show('解析失败', parseFail);
|
||||
show('往返不一致', roundFail);
|
||||
|
||||
const bad = byteFail.length + parseFail.length + roundFail.length;
|
||||
process.exit(bad === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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<!-- probe -->\n' });
|
||||
const editedText = fs.readFileSync(file, 'utf8');
|
||||
if (fmBefore !== fmBlockOf(editedText)) {
|
||||
mark = '✗';
|
||||
failures.push(item.id + ' → 改正文时 front matter 被改动');
|
||||
}
|
||||
if (!editedText.includes('<!-- probe -->')) {
|
||||
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);
|
||||
Reference in new issue
Block a user