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);
|
||||
Reference in new issue
Block a user