@code2rich/jpage 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- package/test/unit/zip.test.js +164 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// 端到端验证套件:覆盖鉴权、上传、列表、渲染、搜索、短链、版本、MCP、缓存头
|
|
2
|
+
// 用法: node test/perf-harness.js [PORT]
|
|
3
|
+
// 退出码 0 = 全部通过, 非 0 = 有失败
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
const PORT = parseInt(process.argv[2] || process.env.PORT || '8890', 10);
|
|
8
|
+
const HOST = '127.0.0.1';
|
|
9
|
+
const ADMIN = 'admin';
|
|
10
|
+
const PASS = 'testpassword123';
|
|
11
|
+
|
|
12
|
+
let pass = 0, fail = 0;
|
|
13
|
+
const failures = [];
|
|
14
|
+
|
|
15
|
+
function check(name, cond, detail) {
|
|
16
|
+
if (cond) { pass++; console.log(` ✓ ${name}`); }
|
|
17
|
+
else { fail++; failures.push(`${name}${detail ? ' :: ' + detail : ''}`); console.log(` ✗ ${name}${detail ? ' :: ' + detail : ''}`); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function req(method, path, { body, headers = {}, raw, formData } = {}) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const opts = { host: HOST, port: PORT, method, path, headers: { ...headers } };
|
|
23
|
+
let payload = null;
|
|
24
|
+
if (raw !== undefined) { payload = raw; if (!opts.headers['Content-Type']) opts.headers['Content-Type'] = 'application/json'; }
|
|
25
|
+
else if (body !== undefined) { payload = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
|
|
26
|
+
else if (formData !== undefined) { payload = formData.body; Object.assign(opts.headers, formData.headers); }
|
|
27
|
+
if (payload) opts.headers['Content-Length'] = Buffer.byteLength(payload);
|
|
28
|
+
const r = http.request(opts, res => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
res.on('data', c => chunks.push(c));
|
|
31
|
+
res.on('end', () => {
|
|
32
|
+
const buf = Buffer.concat(chunks);
|
|
33
|
+
resolve({ status: res.statusCode, headers: res.headers, buf, text: buf.toString('utf8') });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
r.on('error', reject);
|
|
37
|
+
if (payload) r.write(payload);
|
|
38
|
+
r.end();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 多部分表单:单字段 file
|
|
43
|
+
function multipart(boundary, field) {
|
|
44
|
+
const CRLF = '\r\n';
|
|
45
|
+
const pre = `--${boundary}${CRLF}Content-Disposition: form-data; name="file"; filename="${field.filename}"${CRLF}Content-Type: ${field.type || 'text/markdown'}${CRLF}${CRLF}`;
|
|
46
|
+
const post = `${CRLF}--${boundary}--${CRLF}`;
|
|
47
|
+
const body = Buffer.concat([Buffer.from(pre, 'utf8'), Buffer.from(field.content, 'utf8'), Buffer.from(post, 'utf8')]);
|
|
48
|
+
return { body, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function run() {
|
|
52
|
+
console.log(`\n=== jpage 验证套件 (port ${PORT}) ===\n`);
|
|
53
|
+
|
|
54
|
+
// 1. 健康检查
|
|
55
|
+
let r = await req('GET', '/health');
|
|
56
|
+
check('GET /health 200', r.status === 200, `status=${r.status}`);
|
|
57
|
+
check('health 报告 db ok', (() => { try { return JSON.parse(r.text).db === true; } catch { return false; } })());
|
|
58
|
+
|
|
59
|
+
// 2. 未登录访问受保护端点 → 401
|
|
60
|
+
r = await req('GET', '/api/auth/me');
|
|
61
|
+
check('未登录 GET /api/auth/me → 401', r.status === 401, `status=${r.status}`);
|
|
62
|
+
|
|
63
|
+
// 3. 登录
|
|
64
|
+
r = await req('POST', '/api/auth/login', { body: { username: ADMIN, password: PASS } });
|
|
65
|
+
check('登录 admin → 200', r.status === 200, `status=${r.status}`);
|
|
66
|
+
const cookie = (r.headers['set-cookie'] || []).map(c => c.split(';')[0]).join('; ');
|
|
67
|
+
check('登录返回 cookie', !!cookie);
|
|
68
|
+
const auth = { Cookie: cookie };
|
|
69
|
+
let me;
|
|
70
|
+
try { me = JSON.parse(r.text); } catch { me = {}; }
|
|
71
|
+
check('登录响应含 id/username/role', !!me.id && !!me.username && me.role === 'admin', JSON.stringify(me));
|
|
72
|
+
|
|
73
|
+
// 4. /api/auth/me
|
|
74
|
+
r = await req('GET', '/api/auth/me', { headers: auth });
|
|
75
|
+
check('GET /api/auth/me → 200 (带 cookie)', r.status === 200, `status=${r.status}`);
|
|
76
|
+
|
|
77
|
+
// 5. 上传 Markdown (JSON)
|
|
78
|
+
const mdContent = '# 标题\n\n```js\nconsole.log("hi");\n```\n\n行内公式 $a^2+b^2=c^2$\n\n搜索关键词 jpage_unique_token_alpha';
|
|
79
|
+
r = await req('POST', '/api/files/upload-json', { headers: auth, body: { name: 'perf-test.md', content: mdContent, isPublic: true } });
|
|
80
|
+
check('upload-json markdown → 200', r.status === 200, `status=${r.status} ${r.text}`);
|
|
81
|
+
let upload = {};
|
|
82
|
+
try { upload = JSON.parse(r.text); } catch {}
|
|
83
|
+
check('上传返回 id + share_key', !!upload.id && !!upload.share_key, r.text);
|
|
84
|
+
const fileId = upload.id;
|
|
85
|
+
const shareKey = upload.share_key;
|
|
86
|
+
|
|
87
|
+
// 6. 列表
|
|
88
|
+
r = await req('GET', '/api/files', { headers: auth });
|
|
89
|
+
check('GET /api/files → 200', r.status === 200, `status=${r.status}`);
|
|
90
|
+
let list = {};
|
|
91
|
+
try { list = JSON.parse(r.text); } catch {}
|
|
92
|
+
check('列表含刚上传文件', Array.isArray(list.files) && list.files.some(f => f.id === fileId), r.text.slice(0, 200));
|
|
93
|
+
check('列表文件含 tags 数组', list.files.some(f => Array.isArray(f.tags)), '');
|
|
94
|
+
check('列表含 pagination', !!list.pagination, '');
|
|
95
|
+
|
|
96
|
+
// 7. 文件详情
|
|
97
|
+
r = await req('GET', `/api/files/${fileId}`, { headers: auth });
|
|
98
|
+
check(`GET /api/files/${fileId} → 200`, r.status === 200, `status=${r.status}`);
|
|
99
|
+
|
|
100
|
+
// 8. 内容
|
|
101
|
+
r = await req('GET', `/api/files/${fileId}/content`, { headers: auth });
|
|
102
|
+
check(`GET /api/files/${fileId}/content → 200`, r.status === 200, `status=${r.status}`);
|
|
103
|
+
|
|
104
|
+
// 9. 渲染
|
|
105
|
+
r = await req('GET', `/api/files/${fileId}/render`, { headers: auth });
|
|
106
|
+
check(`render → 200`, r.status === 200, `status=${r.status}`);
|
|
107
|
+
check('render 返回 html 且含高亮/katex', r.text.includes('<code') || r.text.includes('katex'), r.text.slice(0, 120));
|
|
108
|
+
|
|
109
|
+
// 10. 搜索(FTS)
|
|
110
|
+
await new Promise(res => setTimeout(res, 400)); // 等 FTS 异步索引
|
|
111
|
+
r = await req('GET', `/api/files/search?q=${encodeURIComponent('jpage_unique_token_alpha')}`, { headers: auth });
|
|
112
|
+
check('FTS 搜索 → 200', r.status === 200, `status=${r.status}`);
|
|
113
|
+
let search = {};
|
|
114
|
+
try { search = JSON.parse(r.text); } catch {}
|
|
115
|
+
check('FTS 搜索命中目标文件', Array.isArray(search.files) && search.files.some(f => f.id === fileId), r.text.slice(0, 200));
|
|
116
|
+
|
|
117
|
+
// 11. 覆盖上传(版本)
|
|
118
|
+
r = await req('POST', `/api/files/${fileId}/overwrite-json`, { headers: auth, body: { content: '# v2\n\n更新内容' } });
|
|
119
|
+
check('overwrite-json → 200', r.status === 200, `status=${r.status} ${r.text}`);
|
|
120
|
+
r = await req('GET', `/api/files/${fileId}/versions`, { headers: auth });
|
|
121
|
+
check('版本列表 → 200', r.status === 200, `status=${r.status}`);
|
|
122
|
+
|
|
123
|
+
// 12. 短链 /s/:key
|
|
124
|
+
r = await req('GET', `/s/${shareKey}`);
|
|
125
|
+
check(`短链 /s/${shareKey} → 200`, r.status === 200, `status=${r.status}`);
|
|
126
|
+
check('短链渲染含 html', r.text.includes('<html') || r.text.includes('<!DOCTYPE'), r.text.slice(0, 80));
|
|
127
|
+
|
|
128
|
+
// 13. 标签 / 分类(含分类名称缓存验证)
|
|
129
|
+
r = await req('POST', '/api/tags', { headers: auth, body: { name: 'perf-tag' } });
|
|
130
|
+
check('创建标签 → 200/201', r.status === 200 || r.status === 201, `status=${r.status}`);
|
|
131
|
+
r = await req('PUT', `/api/files/${fileId}/tags`, { headers: auth, body: { tagIds: [1] } });
|
|
132
|
+
check('给文件打标签 → 200', r.status === 200, `status=${r.status}`);
|
|
133
|
+
r = await req('POST', '/api/categories', { headers: auth, body: { name: '缓存验证分类' } });
|
|
134
|
+
check('创建分类 → 200', r.status === 200, `status=${r.status}`);
|
|
135
|
+
let createdCat = {};
|
|
136
|
+
try { createdCat = JSON.parse(r.text); } catch {}
|
|
137
|
+
if (createdCat.id) {
|
|
138
|
+
r = await req('PUT', `/api/files/${fileId}/category`, { headers: auth, body: { categoryId: createdCat.id } });
|
|
139
|
+
check('给文件设置分类 → 200', r.status === 200, `status=${r.status}`);
|
|
140
|
+
// 列表应通过分类缓存返回正确的 category_name
|
|
141
|
+
r = await req('GET', '/api/files?keyword=perf-test.md', { headers: auth });
|
|
142
|
+
let withCat = {};
|
|
143
|
+
try { withCat = JSON.parse(r.text); } catch {}
|
|
144
|
+
const target = (withCat.files || []).find(f => f.id === fileId);
|
|
145
|
+
check('列表 category_name 由缓存正确填充', !!target && target.category_name === '缓存验证分类',
|
|
146
|
+
target ? `got category_name="${target.category_name}"` : 'file not found in list');
|
|
147
|
+
// 重命名后缓存应失效,列表反映新名
|
|
148
|
+
r = await req('PUT', `/api/categories/${createdCat.id}`, { headers: auth, body: { name: '重命名后的分类' } });
|
|
149
|
+
check('重命名分类 → 200', r.status === 200, `status=${r.status}`);
|
|
150
|
+
r = await req('GET', '/api/files?keyword=perf-test.md', { headers: auth });
|
|
151
|
+
let renamed = {};
|
|
152
|
+
try { renamed = JSON.parse(r.text); } catch {}
|
|
153
|
+
const target2 = (renamed.files || []).find(f => f.id === fileId);
|
|
154
|
+
check('分类重命名后列表反映新名称(缓存失效)', !!target2 && target2.category_name === '重命名后的分类',
|
|
155
|
+
target2 ? `got category_name="${target2.category_name}"` : 'file not found');
|
|
156
|
+
}
|
|
157
|
+
r = await req('GET', '/api/categories', { headers: auth });
|
|
158
|
+
check('GET /api/categories → 200', r.status === 200, `status=${r.status}`);
|
|
159
|
+
|
|
160
|
+
// 14. 静态资源缓存头(优化后应为长缓存)
|
|
161
|
+
r = await req('GET', '/css/style.css?v=1.5.0');
|
|
162
|
+
check('GET /css/style.css → 200', r.status === 200, `status=${r.status}`);
|
|
163
|
+
const cssCache = r.headers['cache-control'] || '';
|
|
164
|
+
console.log(` (css cache-control: "${cssCache}")`);
|
|
165
|
+
|
|
166
|
+
// 15. 删除文件
|
|
167
|
+
r = await req('DELETE', `/api/files/${fileId}`, { headers: auth });
|
|
168
|
+
check('删除文件 → 200', r.status === 200, `status=${r.status}`);
|
|
169
|
+
|
|
170
|
+
// 16. 二次渲染(缓存路径,若启用)
|
|
171
|
+
// 重新上传一个用于短链热路径
|
|
172
|
+
r = await req('POST', '/api/files/upload-json', { headers: auth, body: { name: 'cache-target.md', content: '# C\n\n缓存验证', isPublic: true } });
|
|
173
|
+
let c2 = {};
|
|
174
|
+
try { c2 = JSON.parse(r.text); } catch {}
|
|
175
|
+
if (c2.id) {
|
|
176
|
+
const t1 = process.hrtime.bigint();
|
|
177
|
+
r = await req('GET', `/api/files/${c2.id}/render`, { headers: auth });
|
|
178
|
+
const t2 = process.hrtime.bigint();
|
|
179
|
+
const ms1 = Number(t2 - t1) / 1e6;
|
|
180
|
+
const t3 = process.hrtime.bigint();
|
|
181
|
+
r = await req('GET', `/api/files/${c2.id}/render`, { headers: auth });
|
|
182
|
+
const t4 = process.hrtime.bigint();
|
|
183
|
+
const ms2 = Number(t4 - t3) / 1e6;
|
|
184
|
+
console.log(` (render cold=${ms1.toFixed(2)}ms, warm=${ms2.toFixed(2)}ms)`);
|
|
185
|
+
check('二次渲染同样成功', r.status === 200, `status=${r.status}`);
|
|
186
|
+
await req('DELETE', `/api/files/${c2.id}`, { headers: auth });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 17. 不存在的资源 id → 404(非 500)
|
|
190
|
+
r = await req('GET', '/api/files/99999/render', { headers: auth });
|
|
191
|
+
check('render 不存在文件 → 404', r.status === 404, `status=${r.status}`);
|
|
192
|
+
|
|
193
|
+
console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`);
|
|
194
|
+
if (fail > 0) { console.log('失败项:'); failures.forEach(f => console.log(' - ' + f)); process.exit(1); }
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
run().catch(e => { console.error('套件异常:', e); process.exit(2); });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 启动一个隔离的 jpage 实例用于测试
|
|
3
|
+
# 用法: PORT=8891 bash test/run-server.sh & (后台)
|
|
4
|
+
# bash test/run-server.sh (前台)
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
PORT="${PORT:-8890}"
|
|
7
|
+
DATA_DIR="${JPAGE_DATA_DIR:-$(pwd)/data-test-$PORT}"
|
|
8
|
+
export PORT NODE_ENV ADMIN_USER ADMIN_PASSWORD JPAGE_DATA_DIR MCP_TOKEN
|
|
9
|
+
NODE_ENV="${NODE_ENV:-development}"
|
|
10
|
+
ADMIN_USER="${ADMIN_USER:-admin}"
|
|
11
|
+
ADMIN_PASSWORD="${ADMIN_PASSWORD:-testpassword123}"
|
|
12
|
+
JPAGE_DATA_DIR="$DATA_DIR"
|
|
13
|
+
MCP_TOKEN="${MCP_TOKEN:-test-mcp-token-abc}"
|
|
14
|
+
mkdir -p "$DATA_DIR"
|
|
15
|
+
exec node server.js
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// bin/args.js argv 解析器单元测试
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const { parse } = require('../../bin/args');
|
|
5
|
+
|
|
6
|
+
test('parse: 空数组 → cmd/sub 为 null', () => {
|
|
7
|
+
const r = parse([]);
|
|
8
|
+
assert.strictEqual(r.cmd, null);
|
|
9
|
+
assert.strictEqual(r.sub, null);
|
|
10
|
+
assert.deepStrictEqual(r.opts, {});
|
|
11
|
+
assert.deepStrictEqual(r.positional, []);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('parse: 单命令', () => {
|
|
15
|
+
const r = parse(['ls']);
|
|
16
|
+
assert.strictEqual(r.cmd, 'ls');
|
|
17
|
+
assert.strictEqual(r.sub, null);
|
|
18
|
+
assert.deepStrictEqual(r.positional, ['ls']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('parse: 命令 + 子命令', () => {
|
|
22
|
+
const r = parse(['skills', 'ls']);
|
|
23
|
+
assert.strictEqual(r.cmd, 'skills');
|
|
24
|
+
assert.strictEqual(r.sub, 'ls');
|
|
25
|
+
assert.deepStrictEqual(r.positional, ['skills', 'ls']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('parse: 命令 + 位置参数(3 个)', () => {
|
|
29
|
+
const r = parse(['mv', '12', 'new.html']);
|
|
30
|
+
assert.strictEqual(r.cmd, 'mv');
|
|
31
|
+
assert.strictEqual(r.sub, '12');
|
|
32
|
+
assert.deepStrictEqual(r.positional, ['mv', '12', 'new.html']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('parse: --key value 形式', () => {
|
|
36
|
+
const r = parse(['ls', '--page', '2', '--limit', '50']);
|
|
37
|
+
assert.strictEqual(r.opts.page, '2');
|
|
38
|
+
assert.strictEqual(r.opts.limit, '50');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('parse: --key=value 形式', () => {
|
|
42
|
+
const r = parse(['ls', '--page=2', '--limit=50']);
|
|
43
|
+
assert.strictEqual(r.opts.page, '2');
|
|
44
|
+
assert.strictEqual(r.opts.limit, '50');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('parse: 布尔标志(无值)', () => {
|
|
48
|
+
const r = parse(['upload', 'a.html', '--public']);
|
|
49
|
+
assert.strictEqual(r.opts.public, true);
|
|
50
|
+
assert.deepStrictEqual(r.positional, ['upload', 'a.html']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('parse: 布尔标志后跟另一个选项时识别为布尔', () => {
|
|
54
|
+
const r = parse(['upload', 'a.html', '--public', '--token', 'x']);
|
|
55
|
+
assert.strictEqual(r.opts.public, true);
|
|
56
|
+
assert.strictEqual(r.opts.token, 'x');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('parse: -- 后内容全部当位置参数', () => {
|
|
60
|
+
const r = parse(['cat', '--', '--weird-id']);
|
|
61
|
+
assert.deepStrictEqual(r.positional, ['cat', '--weird-id']);
|
|
62
|
+
assert.strictEqual(r.opts['weird-id'], undefined);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('parse: 选项在命令之前也能解析', () => {
|
|
66
|
+
const r = parse(['--token', 'abc', 'ls', '--page', '1']);
|
|
67
|
+
assert.strictEqual(r.opts.token, 'abc');
|
|
68
|
+
assert.strictEqual(r.cmd, 'ls');
|
|
69
|
+
assert.strictEqual(r.opts.page, '1');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('parse: --token=value 与位置参数混合', () => {
|
|
73
|
+
const r = parse(['--token=abc', 'upload', 'x.html']);
|
|
74
|
+
assert.strictEqual(r.opts.token, 'abc');
|
|
75
|
+
assert.strictEqual(r.cmd, 'upload');
|
|
76
|
+
assert.strictEqual(r.sub, 'x.html');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('parse: 末尾布尔标志(无后续 token)', () => {
|
|
80
|
+
const r = parse(['upload', 'x.html', '--public']);
|
|
81
|
+
assert.strictEqual(r.opts.public, true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('parse: 多个布尔标志', () => {
|
|
85
|
+
const r = parse(['x', '--public', '--verbose']);
|
|
86
|
+
assert.strictEqual(r.opts.public, true);
|
|
87
|
+
assert.strictEqual(r.opts.verbose, true);
|
|
88
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// bin/config.js 配置解析单元测试
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { resolveConfig, parseEnvFile, loadEnvUp, DEFAULT_BASE } = require('../../bin/config');
|
|
8
|
+
|
|
9
|
+
test('parseEnvFile: 基本 KEY=VALUE', () => {
|
|
10
|
+
const tmp = path.join(os.tmpdir(), '.env-test-' + process.pid);
|
|
11
|
+
fs.writeFileSync(tmp, 'MCP_TOKEN=abc123\nJPAGE_BASE=http://1.2.3.4:8858\n');
|
|
12
|
+
const parsed = parseEnvFile(tmp);
|
|
13
|
+
assert.strictEqual(parsed.MCP_TOKEN, 'abc123');
|
|
14
|
+
assert.strictEqual(parsed.JPAGE_BASE, 'http://1.2.3.4:8858');
|
|
15
|
+
fs.unlinkSync(tmp);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('parseEnvFile: 去引号 + 跳过注释/空行', () => {
|
|
19
|
+
const tmp = path.join(os.tmpdir(), '.env-test2-' + process.pid);
|
|
20
|
+
fs.writeFileSync(tmp, '# 注释\nFOO="bar baz"\nQUOTED=\'single\'\n\n');
|
|
21
|
+
const parsed = parseEnvFile(tmp);
|
|
22
|
+
assert.strictEqual(parsed.FOO, 'bar baz');
|
|
23
|
+
assert.strictEqual(parsed.QUOTED, 'single');
|
|
24
|
+
fs.unlinkSync(tmp);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('parseEnvFile: 不存在返回空对象', () => {
|
|
28
|
+
assert.deepStrictEqual(parseEnvFile('/no/such/path/.env'), {});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('resolveConfig: --token 最高优先级', () => {
|
|
32
|
+
const r = resolveConfig({ token: 'cli' }, { JPAGE_TOKEN: 'env', MCP_TOKEN: 'global' });
|
|
33
|
+
assert.strictEqual(r.token, 'cli');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('resolveConfig: JPAGE_TOKEN 高于 MCP_TOKEN', () => {
|
|
37
|
+
const r = resolveConfig({}, { JPAGE_TOKEN: 'jp', MCP_TOKEN: 'mc' });
|
|
38
|
+
assert.strictEqual(r.token, 'jp');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('resolveConfig: 回退 MCP_TOKEN', () => {
|
|
42
|
+
const r = resolveConfig({}, { MCP_TOKEN: 'mc' });
|
|
43
|
+
assert.strictEqual(r.token, 'mc');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('resolveConfig: 无 token 返回 null', () => {
|
|
47
|
+
// 用临时 cwd,避免读到项目根的 .env(其中有 MCP_TOKEN)造成泄漏
|
|
48
|
+
const cleanCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'jpage-clean-'));
|
|
49
|
+
try {
|
|
50
|
+
const r = resolveConfig({}, {}, cleanCwd);
|
|
51
|
+
assert.strictEqual(r.token, null);
|
|
52
|
+
} finally {
|
|
53
|
+
fs.rmSync(cleanCwd, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('resolveConfig: --base 优先于环境变量', () => {
|
|
58
|
+
const r = resolveConfig({ base: 'http://cli:9' }, { JPAGE_BASE: 'http://env:9' });
|
|
59
|
+
assert.strictEqual(r.base, 'http://cli:9');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('resolveConfig: base 去尾部斜杠', () => {
|
|
63
|
+
const r = resolveConfig({ base: 'http://x:9///' }, {});
|
|
64
|
+
assert.strictEqual(r.base, 'http://x:9');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('resolveConfig: 默认 base', () => {
|
|
68
|
+
assert.strictEqual(resolveConfig({}, {}).base, DEFAULT_BASE);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('loadEnvUp: 向上查找 .env 并合并', () => {
|
|
72
|
+
// 在临时目录树里放 .env,验证向上查找
|
|
73
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jpage-env-'));
|
|
74
|
+
const sub = path.join(root, 'a', 'b');
|
|
75
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(root, '.env'), 'MCP_TOKEN=root-token\nJPAGE_BASE=http://root:9\n');
|
|
77
|
+
const loaded = loadEnvUp(sub);
|
|
78
|
+
assert.strictEqual(loaded.MCP_TOKEN, 'root-token');
|
|
79
|
+
assert.strictEqual(loaded.JPAGE_BASE, 'http://root:9');
|
|
80
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('resolveConfig: .env 里的 MCP_TOKEN 被用作回退', () => {
|
|
84
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jpage-env2-'));
|
|
85
|
+
fs.writeFileSync(path.join(root, '.env'), 'MCP_TOKEN=from-file\n');
|
|
86
|
+
const r = resolveConfig({}, {}, root);
|
|
87
|
+
assert.strictEqual(r.token, 'from-file');
|
|
88
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
89
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// lib/crypto.js 单元测试:AES-256-GCM 加解密往返、密钥持久化、密钥变更后解密失败。
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
// 用临时数据目录隔离密钥文件
|
|
9
|
+
const TMP_DATA = path.join(__dirname, '..', `data-crypto-${process.pid}-${Date.now()}`);
|
|
10
|
+
|
|
11
|
+
function freshCrypto() {
|
|
12
|
+
// 清除缓存,让 lib/crypto.js 重新读取 JPAGE_DATA_DIR / 环境变量
|
|
13
|
+
delete require.cache[require.resolve('../../lib/paths')];
|
|
14
|
+
delete require.cache[require.resolve('../../lib/crypto')];
|
|
15
|
+
return require('../../lib/crypto');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test.beforeEach(() => {
|
|
19
|
+
fs.rmSync(TMP_DATA, { recursive: true, force: true });
|
|
20
|
+
fs.mkdirSync(TMP_DATA, { recursive: true });
|
|
21
|
+
delete process.env.TOKEN_ENCRYPTION_KEY;
|
|
22
|
+
process.env.JPAGE_DATA_DIR = TMP_DATA;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test.afterEach(() => {
|
|
26
|
+
fs.rmSync(TMP_DATA, { recursive: true, force: true });
|
|
27
|
+
delete process.env.TOKEN_ENCRYPTION_KEY;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('加密后解密能还原原文', () => {
|
|
31
|
+
const { encryptToken, decryptToken } = freshCrypto();
|
|
32
|
+
const plain = 'jp_abcdefghijklmnopqrstuvwxyz123456';
|
|
33
|
+
const enc = encryptToken(plain);
|
|
34
|
+
assert.notStrictEqual(enc, plain, '密文不应等于明文');
|
|
35
|
+
assert.strictEqual(decryptToken(enc), plain);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('同一明文每次加密结果不同(随机 IV)', () => {
|
|
39
|
+
const { encryptToken } = freshCrypto();
|
|
40
|
+
const plain = 'jp_sametokenvalue';
|
|
41
|
+
const a = encryptToken(plain);
|
|
42
|
+
const b = encryptToken(plain);
|
|
43
|
+
assert.notStrictEqual(a, b, '不同次加密应产生不同密文');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('密文格式为 iv : ciphertext : authTag 三段 base64', () => {
|
|
47
|
+
const { encryptToken } = freshCrypto();
|
|
48
|
+
const enc = encryptToken('jp_test');
|
|
49
|
+
const parts = enc.split(':');
|
|
50
|
+
assert.strictEqual(parts.length, 3);
|
|
51
|
+
// 每段都是合法 base64
|
|
52
|
+
parts.forEach(p => assert.ok(Buffer.from(p, 'base64').length > 0));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('密钥文件自动生成在数据目录并持久化', () => {
|
|
56
|
+
const { encryptToken } = freshCrypto();
|
|
57
|
+
const enc = encryptToken('jp_persist');
|
|
58
|
+
const keyFile = path.join(TMP_DATA, 'token-key.key');
|
|
59
|
+
assert.ok(fs.existsSync(keyFile), '应生成 token-key.key 文件');
|
|
60
|
+
const hex = fs.readFileSync(keyFile, 'utf8').trim();
|
|
61
|
+
assert.match(hex, /^[0-9a-fA-F]{64}$/, '密钥文件为 32 字节 hex');
|
|
62
|
+
|
|
63
|
+
// 再次 require(重新读文件)应能解密旧密文
|
|
64
|
+
const { decryptToken: decryptAgain } = freshCrypto();
|
|
65
|
+
assert.strictEqual(decryptAgain(enc), 'jp_persist');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('密文被篡改 → 解密抛错(GCM 完整性校验)', () => {
|
|
69
|
+
const { encryptToken, decryptToken } = freshCrypto();
|
|
70
|
+
const enc = encryptToken('jp_tamper');
|
|
71
|
+
const parts = enc.split(':');
|
|
72
|
+
// 篡改 ciphertext 段的第一个字符
|
|
73
|
+
const tamperedData = Buffer.from(parts[1], 'base64');
|
|
74
|
+
tamperedData[0] ^= 0xff;
|
|
75
|
+
const tampered = [parts[0], tamperedData.toString('base64'), parts[2]].join(':');
|
|
76
|
+
assert.throws(() => decryptToken(tampered), /unsupported|auth|decrypt|final/i);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('格式错误的密文 → 解密抛错', () => {
|
|
80
|
+
const { decryptToken } = freshCrypto();
|
|
81
|
+
assert.throws(() => decryptToken('not-a-valid-ciphertext'));
|
|
82
|
+
assert.throws(() => decryptToken('a:b'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('环境变量 TOKEN_ENCRYPTION_KEY 优先于密钥文件(合法 hex)', () => {
|
|
86
|
+
process.env.TOKEN_ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
|
|
87
|
+
const { encryptToken, decryptToken } = freshCrypto();
|
|
88
|
+
const enc = encryptToken('jp_env_key');
|
|
89
|
+
assert.strictEqual(decryptToken(enc), 'jp_env_key');
|
|
90
|
+
assert.ok(!fs.existsSync(path.join(TMP_DATA, 'token-key.key')), '用环境变量时不应生成密钥文件');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('reloadKey() 切换密钥后旧密文无法解密', () => {
|
|
94
|
+
const mod = freshCrypto();
|
|
95
|
+
const enc = mod.encryptToken('jp_rotate');
|
|
96
|
+
// 换一个新环境变量密钥并 reload
|
|
97
|
+
process.env.TOKEN_ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
|
|
98
|
+
mod.reloadKey();
|
|
99
|
+
assert.throws(() => mod.decryptToken(enc), '换密钥后旧密文应无法解密');
|
|
100
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// lib/fts.js 单元测试
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { escapeFtsQuery, isFtsIndexable } = require('../../lib/fts');
|
|
6
|
+
|
|
7
|
+
test('isFtsIndexable:可索引扩展名', () => {
|
|
8
|
+
assert.ok(isFtsIndexable('html', 'a.html'));
|
|
9
|
+
assert.ok(isFtsIndexable('markdown', 'b.md'));
|
|
10
|
+
assert.ok(isFtsIndexable('html', 'c.MD')); // 大写
|
|
11
|
+
assert.ok(isFtsIndexable('markdown', 'd.markdown'));
|
|
12
|
+
assert.ok(isFtsIndexable('text', 'e.txt'));
|
|
13
|
+
assert.ok(isFtsIndexable('html', 'f.HTM'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('isFtsIndexable:不可索引', () => {
|
|
17
|
+
assert.ok(!isFtsIndexable('bundle', 'a.html')); // bundle 永不索引
|
|
18
|
+
assert.ok(!isFtsIndexable('html', 'a.png'));
|
|
19
|
+
assert.ok(!isFtsIndexable('html', 'noext'));
|
|
20
|
+
assert.ok(!isFtsIndexable('html', ''));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('escapeFtsQuery:移除 FTS5 特殊字符(特殊字符替换为空格后分词)', () => {
|
|
24
|
+
const r = escapeFtsQuery('hello"world*test');
|
|
25
|
+
// " 和 * 是 FTS5 特殊字符,替换为空格后分成三个 token
|
|
26
|
+
assert.strictEqual(r, '"hello" "world" "test"');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('escapeFtsQuery:空查询返回空串', () => {
|
|
30
|
+
assert.strictEqual(escapeFtsQuery(''), '');
|
|
31
|
+
assert.strictEqual(escapeFtsQuery(' '), '');
|
|
32
|
+
assert.strictEqual(escapeFtsQuery('"*()'), '');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('escapeFtsQuery:每个 token 被双引号包裹', () => {
|
|
36
|
+
const r = escapeFtsQuery('hello world');
|
|
37
|
+
assert.strictEqual(r, '"hello" "world"');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('escapeFtsQuery:CJK 逐字分词', () => {
|
|
41
|
+
const r = escapeFtsQuery('测试');
|
|
42
|
+
// 每个 CJK 字符前后加空格,最终每个字单独成 token
|
|
43
|
+
assert.ok(r.includes('"测"'));
|
|
44
|
+
assert.ok(r.includes('"试"'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('escapeFtsQuery:CJK + ASCII 混合', () => {
|
|
48
|
+
const r = escapeFtsQuery('api 测试');
|
|
49
|
+
assert.ok(r.includes('"api"'));
|
|
50
|
+
assert.ok(r.includes('"测"'));
|
|
51
|
+
assert.ok(r.includes('"试"'));
|
|
52
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// lib/render-cache.js 单元测试
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const {
|
|
5
|
+
renderCacheKey,
|
|
6
|
+
getRenderedHtml,
|
|
7
|
+
setRenderedHtml,
|
|
8
|
+
invalidateRenderCache,
|
|
9
|
+
clearRenderCache,
|
|
10
|
+
} = require('../../lib/render-cache');
|
|
11
|
+
|
|
12
|
+
function fakeFile(id, overrides = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
stored_name: `f${id}.md`,
|
|
16
|
+
updated_at: '2026-01-01 00:00:00',
|
|
17
|
+
is_bundle: 0,
|
|
18
|
+
entry_path: null,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('renderCacheKey:含 id/stored_name/updated_at/is_bundle/entry_path', () => {
|
|
24
|
+
const key = renderCacheKey(fakeFile(1));
|
|
25
|
+
assert.ok(key.startsWith('1:f1.md:2026-01-01 00:00:00:0:'));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('renderCacheKey:stored_name 不同则 key 不同(历史版本场景)', () => {
|
|
29
|
+
const a = renderCacheKey(fakeFile(1, { stored_name: 'current.md' }));
|
|
30
|
+
const b = renderCacheKey(fakeFile(1, { stored_name: 'v2.md' }));
|
|
31
|
+
assert.notStrictEqual(a, b);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('getRenderedHtml:未缓存返回 null', () => {
|
|
35
|
+
clearRenderCache();
|
|
36
|
+
assert.strictEqual(getRenderedHtml(fakeFile(99)), null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('setRenderedHtml / getRenderedHtml:写入后命中', () => {
|
|
40
|
+
clearRenderCache();
|
|
41
|
+
const f = fakeFile(2);
|
|
42
|
+
setRenderedHtml(f, '<html></html>');
|
|
43
|
+
assert.strictEqual(getRenderedHtml(f), '<html></html>');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('setRenderedHtml:updated_at 变化后旧缓存失效', () => {
|
|
47
|
+
clearRenderCache();
|
|
48
|
+
const f1 = fakeFile(3, { updated_at: '2026-01-01 00:00:00' });
|
|
49
|
+
setRenderedHtml(f1, 'old');
|
|
50
|
+
const f2 = fakeFile(3, { updated_at: '2026-01-02 00:00:00' });
|
|
51
|
+
assert.strictEqual(getRenderedHtml(f2), null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('invalidateRenderCache:按 fileId 清除该文件全部缓存', () => {
|
|
55
|
+
clearRenderCache();
|
|
56
|
+
setRenderedHtml(fakeFile(5, { stored_name: 'a.md' }), 'a');
|
|
57
|
+
setRenderedHtml(fakeFile(5, { stored_name: 'b.md' }), 'b');
|
|
58
|
+
setRenderedHtml(fakeFile(6), 'other');
|
|
59
|
+
invalidateRenderCache(5);
|
|
60
|
+
assert.strictEqual(getRenderedHtml(fakeFile(5, { stored_name: 'a.md' })), null);
|
|
61
|
+
assert.strictEqual(getRenderedHtml(fakeFile(5, { stored_name: 'b.md' })), null);
|
|
62
|
+
assert.ok(getRenderedHtml(fakeFile(6))); // 其它文件不受影响
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('LRU 淘汰:超过 256 条时淘汰最早的', () => {
|
|
66
|
+
clearRenderCache();
|
|
67
|
+
// 填满 256 条
|
|
68
|
+
for (let i = 0; i < 256; i++) {
|
|
69
|
+
setRenderedHtml(fakeFile(1000 + i), `html${i}`);
|
|
70
|
+
}
|
|
71
|
+
// 第 257 条触发淘汰 id=1000 的
|
|
72
|
+
setRenderedHtml(fakeFile(2000), 'new');
|
|
73
|
+
assert.strictEqual(getRenderedHtml(fakeFile(1000)), null);
|
|
74
|
+
assert.strictEqual(getRenderedHtml(fakeFile(2000)), 'new');
|
|
75
|
+
clearRenderCache();
|
|
76
|
+
});
|