Files
blog/editor-api/test/api-e2e.mjs
T
zqlit c217d20c30 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
2026-10-04 21:16:06 +08:00

239 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);