@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,89 @@
|
|
|
1
|
+
// 令牌集成测试:创建 / 列表(viewable) / 查看明文 / 删除 / 鉴权边界
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const request = require('supertest');
|
|
5
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
6
|
+
|
|
7
|
+
let env;
|
|
8
|
+
let agent;
|
|
9
|
+
|
|
10
|
+
test.before(async () => {
|
|
11
|
+
env = createTestEnv();
|
|
12
|
+
await env.ready();
|
|
13
|
+
agent = request.agent(env.app);
|
|
14
|
+
await agent.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test.after(() => {
|
|
18
|
+
env.cleanup();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('创建令牌 → 返回明文且带 jp_ 前缀', async () => {
|
|
22
|
+
const res = await agent.post('/api/tokens').send({ name: 'CI Token' });
|
|
23
|
+
assert.strictEqual(res.status, 200);
|
|
24
|
+
assert.ok(res.body.token.startsWith('jp_'));
|
|
25
|
+
assert.strictEqual(res.body.name, 'CI Token');
|
|
26
|
+
assert.ok(res.body.token_prefix);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('列表包含 viewable=true(新建令牌有加密明文)', async () => {
|
|
30
|
+
const res = await agent.get('/api/tokens');
|
|
31
|
+
assert.strictEqual(res.status, 200);
|
|
32
|
+
assert.ok(res.body.tokens.length > 0);
|
|
33
|
+
const t = res.body.tokens[0];
|
|
34
|
+
assert.strictEqual(t.viewable, 1, '新建令牌应有可查看的加密明文');
|
|
35
|
+
// 列表不应返回明文或密文
|
|
36
|
+
assert.strictEqual(t.token, undefined);
|
|
37
|
+
assert.strictEqual(t.token_enc, undefined);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('查看令牌明文 → 与创建时一致', async () => {
|
|
41
|
+
const create = await agent.post('/api/tokens').send({ name: 'Reveal Test' });
|
|
42
|
+
const createdToken = create.body.token;
|
|
43
|
+
const list = await agent.get('/api/tokens');
|
|
44
|
+
const item = list.body.tokens.find(t => t.name === 'Reveal Test');
|
|
45
|
+
assert.ok(item);
|
|
46
|
+
|
|
47
|
+
const reveal = await agent.post('/api/tokens/' + item.id + '/reveal');
|
|
48
|
+
assert.strictEqual(reveal.status, 200);
|
|
49
|
+
assert.strictEqual(reveal.body.token, createdToken, 'reveal 返回的明文应与创建时一致');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('reveal 无效 ID → 400', async () => {
|
|
53
|
+
const res = await agent.post('/api/tokens/abc/reveal');
|
|
54
|
+
assert.strictEqual(res.status, 400);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('reveal 不存在的令牌 → 404', async () => {
|
|
58
|
+
const res = await agent.post('/api/tokens/999999/reveal');
|
|
59
|
+
assert.strictEqual(res.status, 404);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('未登录 reveal → 401', async () => {
|
|
63
|
+
const list = await agent.get('/api/tokens');
|
|
64
|
+
const item = list.body.tokens[0];
|
|
65
|
+
const res = await request(env.app).post('/api/tokens/' + item.id + '/reveal');
|
|
66
|
+
assert.strictEqual(res.status, 401);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('删除令牌 → 200,之后 reveal 该令牌 → 404', async () => {
|
|
70
|
+
await agent.post('/api/tokens').send({ name: 'ToDelete' });
|
|
71
|
+
const list = await agent.get('/api/tokens');
|
|
72
|
+
const item = list.body.tokens.find(t => t.name === 'ToDelete');
|
|
73
|
+
|
|
74
|
+
const del = await agent.delete('/api/tokens/' + item.id);
|
|
75
|
+
assert.strictEqual(del.status, 200);
|
|
76
|
+
|
|
77
|
+
const reveal = await agent.post('/api/tokens/' + item.id + '/reveal');
|
|
78
|
+
assert.strictEqual(reveal.status, 404);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('Bearer 鉴权用返回的明文可访问受保护端点', async () => {
|
|
82
|
+
const create = await agent.post('/api/tokens').send({ name: 'Bearer Test' });
|
|
83
|
+
const token = create.body.token;
|
|
84
|
+
// /api/files 由 requireAuth 保护,支持 session 与 Bearer
|
|
85
|
+
const res = await request(env.app)
|
|
86
|
+
.get('/api/files')
|
|
87
|
+
.set('Authorization', 'Bearer ' + token);
|
|
88
|
+
assert.strictEqual(res.status, 200);
|
|
89
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// 用户管理集成测试(仅 admin):列表 / 创建 / 更新 / 删除 / 校验 / 权限边界。
|
|
2
|
+
// 挂载点 /api/users,全部 requireAuth + requireAdmin。
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const request = require('supertest');
|
|
6
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
7
|
+
|
|
8
|
+
let env;
|
|
9
|
+
let adminAgent;
|
|
10
|
+
|
|
11
|
+
test.before(async () => {
|
|
12
|
+
env = createTestEnv();
|
|
13
|
+
await env.ready();
|
|
14
|
+
adminAgent = request.agent(env.app);
|
|
15
|
+
await adminAgent.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test.after(() => {
|
|
19
|
+
env.cleanup();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// --- 权限边界 ---
|
|
23
|
+
test('未登录 GET /api/users → 401', async () => {
|
|
24
|
+
const res = await request(env.app).get('/api/users');
|
|
25
|
+
assert.strictEqual(res.status, 401);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('普通用户 GET /api/users → 403', async () => {
|
|
29
|
+
await adminAgent.post('/api/users').send({ username: 'regular', password: 'regularpass123', role: 'user' });
|
|
30
|
+
const userAgent = request.agent(env.app);
|
|
31
|
+
await userAgent.post('/api/auth/login').send({ username: 'regular', password: 'regularpass123' });
|
|
32
|
+
const res = await userAgent.get('/api/users');
|
|
33
|
+
assert.strictEqual(res.status, 403);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// --- 列表 ---
|
|
37
|
+
test('admin GET /api/users → 200,含 emailVerified 布尔', async () => {
|
|
38
|
+
const res = await adminAgent.get('/api/users');
|
|
39
|
+
assert.strictEqual(res.status, 200);
|
|
40
|
+
assert.ok(Array.isArray(res.body.users));
|
|
41
|
+
assert.ok(res.body.users.length > 0);
|
|
42
|
+
// admin 用户存在
|
|
43
|
+
assert.ok(res.body.users.some(u => u.username === 'admin'));
|
|
44
|
+
// emailVerified 字段存在且为布尔
|
|
45
|
+
assert.strictEqual(typeof res.body.users[0].emailVerified, 'boolean');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// --- 创建 + 校验 ---
|
|
49
|
+
test('创建用户:用户名太短 → 400', async () => {
|
|
50
|
+
const res = await adminAgent.post('/api/users').send({ username: 'a', password: 'validpass123', role: 'user' });
|
|
51
|
+
assert.strictEqual(res.status, 400);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('创建用户:密码 < 8 位 → 400', async () => {
|
|
55
|
+
const res = await adminAgent.post('/api/users').send({ username: 'newuser2', password: 'short', role: 'user' });
|
|
56
|
+
assert.strictEqual(res.status, 400);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('创建用户:无效角色 → 400', async () => {
|
|
60
|
+
const res = await adminAgent.post('/api/users').send({ username: 'newuser3', password: 'validpass123', role: 'superadmin' });
|
|
61
|
+
assert.strictEqual(res.status, 400);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('创建用户:缺用户名或密码 → 400', async () => {
|
|
65
|
+
const res = await adminAgent.post('/api/users').send({ username: 'noname' });
|
|
66
|
+
assert.strictEqual(res.status, 400);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('创建用户:邮箱格式错误 → 400', async () => {
|
|
70
|
+
const res = await adminAgent.post('/api/users').send({ username: 'newuser4', password: 'validpass123', role: 'user', email: 'not-an-email' });
|
|
71
|
+
assert.strictEqual(res.status, 400);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('创建用户:happy path → 200,返回 id', async () => {
|
|
75
|
+
const res = await adminAgent.post('/api/users').send({ username: 'happyuser', password: 'validpass123', role: 'user' });
|
|
76
|
+
assert.strictEqual(res.status, 200);
|
|
77
|
+
assert.ok(res.body.id);
|
|
78
|
+
assert.strictEqual(res.body.username, 'happyuser');
|
|
79
|
+
assert.strictEqual(res.body.role, 'user');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('创建用户:用户名冲突 → 409', async () => {
|
|
83
|
+
// happyuser 已创建
|
|
84
|
+
const res = await adminAgent.post('/api/users').send({ username: 'happyuser', password: 'validpass123', role: 'user' });
|
|
85
|
+
assert.strictEqual(res.status, 409);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// --- 更新 ---
|
|
89
|
+
test('更新用户:无更新字段 → 400', async () => {
|
|
90
|
+
const create = await adminAgent.post('/api/users').send({ username: 'updateable', password: 'validpass123', role: 'user' });
|
|
91
|
+
const res = await adminAgent.put(`/api/users/${create.body.id}`).send({});
|
|
92
|
+
assert.strictEqual(res.status, 400);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('更新用户:重置密码 → 200', async () => {
|
|
96
|
+
const create = await adminAgent.post('/api/users').send({ username: 'pwdreset', password: 'validpass123', role: 'user' });
|
|
97
|
+
const res = await adminAgent.put(`/api/users/${create.body.id}`).send({ password: 'newpassword123' });
|
|
98
|
+
assert.strictEqual(res.status, 200);
|
|
99
|
+
assert.strictEqual(res.body.success, true);
|
|
100
|
+
// 用新密码能登录
|
|
101
|
+
const userAgent = request.agent(env.app);
|
|
102
|
+
const login = await userAgent.post('/api/auth/login').send({ username: 'pwdreset', password: 'newpassword123' });
|
|
103
|
+
assert.strictEqual(login.status, 200);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('更新用户:角色切换 → 200', async () => {
|
|
107
|
+
const create = await adminAgent.post('/api/users').send({ username: 'roleswap', password: 'validpass123', role: 'user' });
|
|
108
|
+
const res = await adminAgent.put(`/api/users/${create.body.id}`).send({ role: 'admin' });
|
|
109
|
+
assert.strictEqual(res.status, 200);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('更新用户:不存在 → 404', async () => {
|
|
113
|
+
const res = await adminAgent.put('/api/users/999999').send({ role: 'user' });
|
|
114
|
+
assert.strictEqual(res.status, 404);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// --- 删除 ---
|
|
118
|
+
test('删除用户:删自己 → 400', async () => {
|
|
119
|
+
// admin 的 id 一般是 1
|
|
120
|
+
const list = await adminAgent.get('/api/users');
|
|
121
|
+
const admin = list.body.users.find(u => u.username === 'admin');
|
|
122
|
+
const res = await adminAgent.delete(`/api/users/${admin.id}`);
|
|
123
|
+
assert.strictEqual(res.status, 400);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('删除用户:不存在 → 404', async () => {
|
|
127
|
+
const res = await adminAgent.delete('/api/users/999999');
|
|
128
|
+
assert.strictEqual(res.status, 404);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('删除用户:happy path → 200', async () => {
|
|
132
|
+
const create = await adminAgent.post('/api/users').send({ username: 'deleteme', password: 'validpass123', role: 'user' });
|
|
133
|
+
const res = await adminAgent.delete(`/api/users/${create.body.id}`);
|
|
134
|
+
assert.strictEqual(res.status, 200);
|
|
135
|
+
// 再列不应有 deleteme
|
|
136
|
+
const list = await adminAgent.get('/api/users');
|
|
137
|
+
assert.ok(!list.body.users.some(u => u.username === 'deleteme'));
|
|
138
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// MCP 端到端验证:通过 /mcp 端点验证 tool 调用(走进程内 dispatcher,非 fetch 自调用)
|
|
2
|
+
// 覆盖 list_files / upload_file / get_file_content / rename_file / get_file_url / delete_file
|
|
3
|
+
// 以及资源 jpage://files
|
|
4
|
+
const http = require('http');
|
|
5
|
+
|
|
6
|
+
const PORT = parseInt(process.argv[2] || process.env.PORT || '8890', 10);
|
|
7
|
+
const HOST = '127.0.0.1';
|
|
8
|
+
const TOKEN = 'test-mcp-token-abc';
|
|
9
|
+
|
|
10
|
+
let pass = 0, fail = 0;
|
|
11
|
+
const failures = [];
|
|
12
|
+
function check(name, cond, detail) {
|
|
13
|
+
if (cond) { pass++; console.log(` ✓ ${name}`); }
|
|
14
|
+
else { fail++; failures.push(`${name}${detail ? ' :: ' + detail : ''}`); console.log(` ✗ ${name}${detail ? ' :: ' + detail : ''}`); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function rawReq(method, path, { headers = {}, body } = {}) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const opts = { host: HOST, port: PORT, method, path, headers: { ...headers } };
|
|
20
|
+
const payload = body !== undefined ? JSON.stringify(body) : null;
|
|
21
|
+
if (payload) { opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Length'] = Buffer.byteLength(payload); }
|
|
22
|
+
const r = http.request(opts, res => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
res.on('data', c => chunks.push(c));
|
|
25
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.concat(chunks), text: Buffer.concat(chunks).toString('utf8') }));
|
|
26
|
+
});
|
|
27
|
+
r.on('error', reject);
|
|
28
|
+
if (payload) r.write(payload);
|
|
29
|
+
r.end();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 从 SSE event-stream 文本里提取 data: {...} 的 JSON(按事件分组,拼接多行 data)
|
|
34
|
+
function parseSseResult(text, id) {
|
|
35
|
+
const events = text.split(/\n\n/);
|
|
36
|
+
for (const ev of events) {
|
|
37
|
+
const dataLines = ev.split('\n').filter(l => l.startsWith('data: ')).map(l => l.slice(6));
|
|
38
|
+
if (!dataLines.length) continue;
|
|
39
|
+
try {
|
|
40
|
+
const obj = JSON.parse(dataLines.join('\n'));
|
|
41
|
+
if (obj.id === id) return obj;
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let _callId = 100;
|
|
48
|
+
async function callTool(headers, sessionId, name, args) {
|
|
49
|
+
const id = ++_callId;
|
|
50
|
+
const r = await rawReq('POST', '/mcp', {
|
|
51
|
+
headers: { ...headers, 'mcp-session-id': sessionId, 'Accept': 'application/json, text/event-stream' },
|
|
52
|
+
body: { jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } },
|
|
53
|
+
});
|
|
54
|
+
const obj = parseSseResult(r.text, id);
|
|
55
|
+
return { status: r.status, obj, text: r.text };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function run() {
|
|
59
|
+
console.log(`\n=== MCP 端到端验证 (port ${PORT}) ===\n`);
|
|
60
|
+
const headers = { Authorization: 'Bearer ' + TOKEN };
|
|
61
|
+
|
|
62
|
+
// 1. initialize 握手
|
|
63
|
+
let r = await rawReq('POST', '/mcp', {
|
|
64
|
+
headers: { ...headers, Accept: 'application/json, text/event-stream' },
|
|
65
|
+
body: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } } },
|
|
66
|
+
});
|
|
67
|
+
check('initialize → 200', r.status === 200, `status=${r.status}`);
|
|
68
|
+
const initObj = parseSseResult(r.text, 1);
|
|
69
|
+
check('initialize 返回 serverInfo', !!(initObj && initObj.result && initObj.result.serverInfo), r.text.slice(0, 200));
|
|
70
|
+
const sessionId = r.headers['mcp-session-id'];
|
|
71
|
+
check('返回 mcp-session-id', !!sessionId, JSON.stringify(r.headers).slice(0, 200));
|
|
72
|
+
if (!sessionId) { console.log('无法继续:无 session'); process.exit(1); }
|
|
73
|
+
|
|
74
|
+
// notifications/initialized(规范要求)
|
|
75
|
+
await rawReq('POST', '/mcp', {
|
|
76
|
+
headers: { ...headers, 'mcp-session-id': sessionId, Accept: 'application/json, text/event-stream' },
|
|
77
|
+
body: { jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 2. tools/list
|
|
81
|
+
r = await rawReq('POST', '/mcp', {
|
|
82
|
+
headers: { ...headers, 'mcp-session-id': sessionId, Accept: 'application/json, text/event-stream' },
|
|
83
|
+
body: { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
84
|
+
});
|
|
85
|
+
const toolsObj = parseSseResult(r.text, 2);
|
|
86
|
+
const toolNames = (toolsObj && toolsObj.result && toolsObj.result.tools || []).map(t => t.name);
|
|
87
|
+
check('tools/list 返回工具', toolNames.length >= 10, `got ${toolNames.length}: ${toolNames.join(',')}`);
|
|
88
|
+
check('含 list_files/upload_file/get_file_content', ['list_files', 'upload_file', 'get_file_content'].every(n => toolNames.includes(n)), toolNames.join(','));
|
|
89
|
+
|
|
90
|
+
// 3. upload_file(dispatcher 直调 upload-json)
|
|
91
|
+
const content = '# MCP 测试\n\n```js\nconsole.log("dispatcher");\n```\n\nmcp_unique_token_xyz';
|
|
92
|
+
r = await callTool(headers, sessionId, 'upload_file', { name: 'mcp-test.md', content, isPublic: true });
|
|
93
|
+
let uploadPayload = null;
|
|
94
|
+
try { uploadPayload = JSON.parse(r.obj.result.content[0].text); } catch {}
|
|
95
|
+
check('upload_file 成功', !!(uploadPayload && uploadPayload.id), r.text.slice(0, 300));
|
|
96
|
+
const fileId = uploadPayload && uploadPayload.id;
|
|
97
|
+
check('upload_file 返回 url', !!(uploadPayload && uploadPayload.url && uploadPayload.url.includes('/s/')), JSON.stringify(uploadPayload).slice(0, 200));
|
|
98
|
+
|
|
99
|
+
// 4. list_files(dispatcher 直调 /api/files)
|
|
100
|
+
r = await callTool(headers, sessionId, 'list_files', { limit: 100 });
|
|
101
|
+
let listPayload = null;
|
|
102
|
+
try { listPayload = JSON.parse(r.obj.result.content[0].text); } catch {}
|
|
103
|
+
check('list_files 成功', !!(listPayload && Array.isArray(listPayload.files)), r.text.slice(0, 200));
|
|
104
|
+
check('list_files 含刚上传文件', !!(listPayload && listPayload.files.some(f => f.id === fileId)), JSON.stringify(listPayload).slice(0, 200));
|
|
105
|
+
|
|
106
|
+
// 5. get_file_content(dispatcher 直调 /api/files/:id/content)
|
|
107
|
+
r = await callTool(headers, sessionId, 'get_file_content', { id: fileId });
|
|
108
|
+
let contentPayload = null;
|
|
109
|
+
try { contentPayload = JSON.parse(r.obj.result.content[0].text); } catch {}
|
|
110
|
+
check('get_file_content 返回内容', !!(contentPayload && contentPayload.content && contentPayload.content.includes('mcp_unique_token_xyz')), r.text.slice(0, 200));
|
|
111
|
+
|
|
112
|
+
// 6. rename_file(dispatcher 直调 PUT /api/files/:id)
|
|
113
|
+
r = await callTool(headers, sessionId, 'rename_file', { id: fileId, name: 'mcp-renamed.md' });
|
|
114
|
+
check('rename_file 成功', !!(r.obj && r.obj.result), r.text.slice(0, 200));
|
|
115
|
+
|
|
116
|
+
// 7. get_file_url(dispatcher 直调 GET /api/files/:id)
|
|
117
|
+
r = await callTool(headers, sessionId, 'get_file_url', { id: fileId });
|
|
118
|
+
let urlPayload = null;
|
|
119
|
+
try { urlPayload = JSON.parse(r.obj.result.content[0].text); } catch {}
|
|
120
|
+
check('get_file_url 返回短链', !!(urlPayload && urlPayload.url && urlPayload.url.includes('/s/')), JSON.stringify(urlPayload).slice(0, 200));
|
|
121
|
+
|
|
122
|
+
// 8. create_category + set_file_category(dispatcher)
|
|
123
|
+
r = await callTool(headers, sessionId, 'create_category', { name: 'mcp-cat' });
|
|
124
|
+
let catPayload = null;
|
|
125
|
+
try { catPayload = JSON.parse(r.obj.result.content[0].text); } catch {}
|
|
126
|
+
check('create_category 成功', !!(catPayload && catPayload.id), r.text.slice(0, 200));
|
|
127
|
+
if (catPayload && catPayload.id) {
|
|
128
|
+
r = await callTool(headers, sessionId, 'set_file_category', { fileId, categoryId: catPayload.id });
|
|
129
|
+
check('set_file_category 成功', !!(r.obj && r.obj.result), r.text.slice(0, 200));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 9. delete_file(dispatcher 直调 DELETE)
|
|
133
|
+
r = await callTool(headers, sessionId, 'delete_file', { id: fileId });
|
|
134
|
+
check('delete_file 成功', !!(r.obj && r.obj.result), r.text.slice(0, 200));
|
|
135
|
+
// 确认真的删了
|
|
136
|
+
r = await callTool(headers, sessionId, 'get_file_content', { id: fileId });
|
|
137
|
+
check('删除后 get_file_content 失败(isError 或无内容)', !!(r.obj && (r.obj.result.isError || (r.obj.error))), r.text.slice(0, 200));
|
|
138
|
+
|
|
139
|
+
// 10. 资源 jpage://files
|
|
140
|
+
r = await rawReq('POST', '/mcp', {
|
|
141
|
+
headers: { ...headers, 'mcp-session-id': sessionId, Accept: 'application/json, text/event-stream' },
|
|
142
|
+
body: { jsonrpc: '2.0', id: 99, method: 'resources/read', params: { uri: 'jpage://files' } },
|
|
143
|
+
});
|
|
144
|
+
const resObj = parseSseResult(r.text, 99);
|
|
145
|
+
check('resources/read jpage://files 成功', !!(resObj && resObj.result && resObj.result.contents), r.text.slice(0, 200));
|
|
146
|
+
|
|
147
|
+
console.log(`\n=== MCP 结果: ${pass} 通过, ${fail} 失败 ===`);
|
|
148
|
+
if (fail > 0) { console.log('失败项:'); failures.forEach(f => console.log(' - ' + f)); process.exit(1); }
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
run().catch(e => { console.error('MCP 套件异常:', e); process.exit(2); });
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// 性能基准:量化优化的实际收益
|
|
2
|
+
// 用法: node test/perf-bench.js [PORT]
|
|
3
|
+
// 测量项:
|
|
4
|
+
// 1) Markdown 渲染冷/热延迟(P0-3 渲染缓存)
|
|
5
|
+
// 2) 文件列表延迟(P1-6 分类缓存)
|
|
6
|
+
// 3) 静态资源缓存头(P0-2)
|
|
7
|
+
// 4) 短链渲染并发吞吐(P0-1 WAL)
|
|
8
|
+
const http = require('http');
|
|
9
|
+
|
|
10
|
+
const PORT = parseInt(process.argv[2] || process.env.PORT || '8890', 10);
|
|
11
|
+
const HOST = '127.0.0.1';
|
|
12
|
+
|
|
13
|
+
function req(method, path, { body, headers = {} } = {}) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const opts = { host: HOST, port: PORT, method, path, headers: { ...headers } };
|
|
16
|
+
const payload = body !== undefined ? JSON.stringify(body) : null;
|
|
17
|
+
if (payload) opts.headers['Content-Type'] = 'application/json', opts.headers['Content-Length'] = Buffer.byteLength(payload);
|
|
18
|
+
const start = process.hrtime.bigint();
|
|
19
|
+
const r = http.request(opts, res => {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
res.on('data', c => chunks.push(c));
|
|
22
|
+
res.on('end', () => {
|
|
23
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
24
|
+
resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.concat(chunks), text: Buffer.concat(chunks).toString('utf8'), ms });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
r.on('error', reject);
|
|
28
|
+
if (payload) r.write(payload);
|
|
29
|
+
r.end();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function login() {
|
|
34
|
+
const r = await req('POST', '/api/auth/login', { body: { username: 'admin', password: 'testpassword123' } });
|
|
35
|
+
const cookie = (r.headers['set-cookie'] || []).map(c => c.split(';')[0]).join('; ');
|
|
36
|
+
return { Cookie: cookie };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pct(arr, p) {
|
|
40
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
41
|
+
return s[Math.floor(s.length * p)] || s[s.length - 1];
|
|
42
|
+
}
|
|
43
|
+
function stats(arr) {
|
|
44
|
+
const sum = arr.reduce((a, b) => a + b, 0);
|
|
45
|
+
return { n: arr.length, mean: (sum / arr.length).toFixed(2), p50: pct(arr, 0.5).toFixed(2), p95: pct(arr, 0.95).toFixed(2), min: Math.min(...arr).toFixed(2), max: Math.max(...arr).toFixed(2) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function run() {
|
|
49
|
+
console.log(`\n=== jpage 性能基准 (port ${PORT}) ===\n`);
|
|
50
|
+
const auth = await login();
|
|
51
|
+
|
|
52
|
+
// 上传一篇含代码块+公式的 Markdown(渲染开销大)
|
|
53
|
+
const md = Array.from({ length: 60 }, (_, i) =>
|
|
54
|
+
`## 章节 ${i}\n\n\`\`\`js\nfunction f${i}(x){ return x*${i}; }\n\`\`\`\n\n公式 $E=mc^2$,块级 $$\\int_0^1 x^2 dx = \\frac{1}{3}$$\n`
|
|
55
|
+
).join('\n');
|
|
56
|
+
let r = await req('POST', '/api/files/upload-json', { headers: auth, body: { name: 'bench.md', content: md, isPublic: true } });
|
|
57
|
+
const fileId = JSON.parse(r.text).id;
|
|
58
|
+
|
|
59
|
+
// --- 1) 渲染冷/热延迟 ---
|
|
60
|
+
// 先覆盖一次让缓存失效(cold),再连续测 warm
|
|
61
|
+
await req('POST', `/api/files/${fileId}/overwrite-json`, { headers: auth, body: { content: md + '\n<!-- cold -->' } });
|
|
62
|
+
const coldSamples = [];
|
|
63
|
+
for (let i = 0; i < 5; i++) {
|
|
64
|
+
// 每次覆盖以清缓存,测冷启动
|
|
65
|
+
await req('POST', `/api/files/${fileId}/overwrite-json`, { headers: auth, body: { content: md + `\n<!-- ${i} -->` } });
|
|
66
|
+
coldSamples.push((await req('GET', `/api/files/${fileId}/render`, { headers: auth })).ms);
|
|
67
|
+
}
|
|
68
|
+
// 热路径:不再覆盖,连续命中缓存
|
|
69
|
+
const warmSamples = [];
|
|
70
|
+
for (let i = 0; i < 50; i++) warmSamples.push((await req('GET', `/api/files/${fileId}/render`, { headers: auth })).ms);
|
|
71
|
+
|
|
72
|
+
console.log('1) Markdown 渲染延迟 (ms):');
|
|
73
|
+
console.log(' 冷渲染 (每次新内容, 无缓存):', stats(coldSamples));
|
|
74
|
+
console.log(' 热渲染 (命中缓存): ', stats(warmSamples));
|
|
75
|
+
const coldMean = +stats(coldSamples).mean, warmMean = +stats(warmSamples).mean;
|
|
76
|
+
if (coldMean > 0) console.log(` → 缓存带来 ${(100 * (1 - warmMean / coldMean)).toFixed(1)}% 延迟下降\n`);
|
|
77
|
+
|
|
78
|
+
// --- 2) 文件列表延迟 ---
|
|
79
|
+
// 预置一些文件让列表有数据
|
|
80
|
+
for (let i = 0; i < 10; i++) {
|
|
81
|
+
await req('POST', '/api/files/upload-json', { headers: auth, body: { name: `list-fill-${i}.md`, content: '# x' } });
|
|
82
|
+
}
|
|
83
|
+
const listSamples = [];
|
|
84
|
+
for (let i = 0; i < 50; i++) listSamples.push((await req('GET', '/api/files?limit=20', { headers: auth })).ms);
|
|
85
|
+
console.log('2) 文件列表延迟 (ms):', stats(listSamples), '\n');
|
|
86
|
+
|
|
87
|
+
// --- 3) 静态资源缓存头 ---
|
|
88
|
+
r = await req('GET', '/css/style.css?v=1.5.0');
|
|
89
|
+
console.log('3) 静态资源 Cache-Control:', JSON.stringify(r.headers['cache-control']));
|
|
90
|
+
console.log(' 包含 immutable:', (r.headers['cache-control'] || '').includes('immutable'), '\n');
|
|
91
|
+
|
|
92
|
+
// --- 4) 短链渲染并发吞吐(WAL 下读写不互斥)---
|
|
93
|
+
const shareKey = (JSON.parse((await req('GET', `/api/files/${fileId}`, { headers: auth })).text)).share_key;
|
|
94
|
+
const N = 60;
|
|
95
|
+
const t0 = process.hrtime.bigint();
|
|
96
|
+
await Promise.all(Array.from({ length: N }, () => req('GET', `/s/${shareKey}`)));
|
|
97
|
+
const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
98
|
+
console.log(`4) 短链 /s/:key 并发 ${N} 次总耗时 ${elapsedMs.toFixed(1)}ms (并发吞吐 ~${(N / elapsedMs * 1000).toFixed(0)} req/s)\n`);
|
|
99
|
+
|
|
100
|
+
// 清理
|
|
101
|
+
for (let i = 0; i < 10; i++) await req('DELETE', `/api/files/${fileId + 1 + i}`, { headers: auth }).catch(() => {});
|
|
102
|
+
await req('DELETE', `/api/files/${fileId}`, { headers: auth }).catch(() => {});
|
|
103
|
+
|
|
104
|
+
console.log('=== 基准完成 ===');
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
run().catch(e => { console.error('bench 异常:', e); process.exit(2); });
|