Repository navigation
Expand file tree
/
Copy pathselftest.mjs
More file actions
395 lines (358 loc) · 15.8 KB
/
Copy pathselftest.mjs
File metadata and controls
395 lines (358 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
#!/usr/bin/env node
/**
* 自测:用本地 mock 上游把关键逻辑跑一遍,不碰真实网络。
*
* 为什么需要它:这个工具有两处「有意偏离上游」的逻辑——免费模型判定规则和
* reasoning_effort 档位表——它们是对着真实上游逐项试出来的,一旦被后续改动
* 或上游变化悄悄改坏,从界面上完全看不出来(表现只是「少了几个模型」或
* 「对话开始 400」)。而代码里早就留好了 ZEN_BASE_URL 这个接缝,指向本地
* mock 就能把这两处钉死。
*
* 跑法:node selftest.mjs
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const PROXY = path.join(SCRIPT_DIR, 'zen-proxy.mjs');
let passed = 0;
let failed = 0;
const failures = [];
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ✓ ${name}`);
} catch (error) {
failed += 1;
failures.push({ name, error });
console.log(` ✗ ${name}`);
console.log(` ${error.message.split('\n')[0]}`);
}
}
async function testAsync(name, fn) {
try {
await fn();
passed += 1;
console.log(` ✓ ${name}`);
} catch (error) {
failed += 1;
failures.push({ name, error });
console.log(` ✗ ${name}`);
console.log(` ${error.message.split('\n')[0]}`);
}
}
// ---------------------------------------------------------------------------
// mock 上游:把 zen-proxy.mjs 需要的两个外部依赖都替换掉
// ZEN_BASE_URL → 本地 http 服务
// METADATA_URL → 固定写死,无法用环境变量替换,所以这里改用
// --config 隔离 + 接受它去打真实地址(只打一次 models.dev)
// ---------------------------------------------------------------------------
/** 假的 Zen:/v1/models 返回清单,/v1/chat/completions 记录收到的 body。 */
async function startMockZen() {
const received = [];
const server = http.createServer((req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
data: [
{ id: 'big-pickle' },
{ id: 'space-bunny-free' },
{ id: 'mimo-v2.5-free' },
{ id: 'muse-spark-1.3-contributor-free' },
{ id: 'paid-model-x' },
],
}),
);
return;
}
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
received.push({ headers: req.headers, body });
res.writeHead(200, { 'content-type': 'text/event-stream' });
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n');
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
return { server, received, baseUrl: `http://127.0.0.1:${server.address().port}` };
}
function tempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'o2z-selftest-'));
}
function readConfig(file) {
return readJsonSafe(file);
}
function readJsonSafe(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/** 跑一次 zen-proxy.mjs,返回 { code, stdout, stderr }。 */
function runProxy(args, env = {}) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROXY, ...args], {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (c) => (stdout += c));
child.stderr.on('data', (c) => (stderr += c));
child.on('close', (code) => resolve({ code, stdout, stderr }));
});
}
function modelIdsOf(configFile, providerId) {
const config = readConfig(configFile);
const rule = config.config.providerConfigRules.providerRules.find((r) => r.providerId === providerId);
return rule ? rule.config.personalModelIds : [];
}
async function main() {
console.log('opencode2zcode 自测(本地 mock,不打真实上游)\n');
const mock = await startMockZen();
const workDir = tempDir();
const configPath = path.join(workDir, 'provider_config.json');
fs.mkdirSync(path.join(workDir, '.zcode', 'v2', 'runtime'), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ schemaVersion: 1, config: {} }, null, 2));
const baseEnv = { ZEN_BASE_URL: mock.baseUrl, ZCODE_DATA_BASE_DIR: workDir };
try {
console.log('[1] 免费判定与下架剔除');
const write = await runProxy(['--write-config-only', '--config', configPath], baseEnv);
assert.equal(write.code, 0, `退出码应为 0,实际 ${write.code}\n${write.stderr}`);
const chat = modelIdsOf(configPath, 'opencode-free');
const responses = modelIdsOf(configPath, 'opencode-free-responses');
test('付费模型不进清单', () => {
assert.ok(!chat.includes('paid-model-x'), 'paid-model-x 不该出现');
});
test('非废弃的免费模型进清单', () => {
assert.ok(chat.includes('big-pickle'), 'big-pickle 应在 chat 清单里');
assert.ok(chat.includes('space-bunny-free'), 'space-bunny-free 应在 chat 清单里');
});
test('默认写 responses 组(门禁修好后这一组真能用)', () => {
assert.ok(
responses.includes('muse-spark-1.3-contributor-free'),
`responses 组应有 muse-spark,实际 ${responses.join(',')}`,
);
});
test('chat 道不含 muse-spark(它只挂在 responses 道)', () => {
assert.ok(!chat.includes('muse-spark-1.3-contributor-free'), 'muse-spark 不该在 chat 道');
});
test('目录判定废弃的模型被剔除(回归:mimo-v2.5-free 曾被误放进来)', () => {
assert.ok(!chat.includes('mimo-v2.5-free'), 'mimo-v2.5-free 已下架,不该出现');
});
test('S3 静态兜底不会复活废弃模型', () => {
// 静态清单里若残留废弃 id,decision() 也必须否决它。
const config = readConfig(configPath);
const rules = config.config.modelConfigRules.providerModelRules;
const ids = rules.map((r) => r.modelId);
assert.ok(!ids.includes('mimo-v2.5-free'), '模型规则里不该有 mimo-v2.5-free');
});
console.log('\n[2] 重复写入的幂等性与「不碰别人的 Provider」');
fs.writeFileSync(
configPath,
JSON.stringify(
{
schemaVersion: 1,
config: {
providerOrder: ['someone-elses'],
providerConfigRules: {
providerRules: [
{ providerId: 'someone-elses', providerName: '别人的', enabled: true, config: {} },
],
},
modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] },
},
},
null,
2,
),
);
await runProxy(['--write-config-only', '--config', configPath], baseEnv);
const merged = readConfig(configPath);
test('别人的 Provider 完好保留', () => {
const ids = merged.config.providerConfigRules.providerRules.map((r) => r.providerId);
assert.ok(ids.includes('someone-elses'), '别人的 providerId 应保留');
});
test('providerOrder 里别人的条目保留', () => {
assert.ok(merged.config.providerOrder.includes('someone-elses'), 'providerOrder 应保留别人的条目');
});
console.log('\n[3] --remove-provider 对称摘除');
const removed = await runProxy(['--remove-provider', '--config', configPath], baseEnv);
assert.equal(removed.code, 0, '卸载应正常退出');
const afterRemove = readConfig(configPath);
test('本工具的两个 Provider 都被摘掉', () => {
const ids = afterRemove.config.providerConfigRules.providerRules.map((r) => r.providerId);
assert.ok(!ids.includes('opencode-free'), 'opencode-free 应被摘掉');
assert.ok(!ids.includes('opencode-free-responses'), 'opencode-free-responses 应被摘掉');
});
test('别人的 Provider 在卸载后依然在', () => {
const ids = afterRemove.config.providerConfigRules.providerRules.map((r) => r.providerId);
assert.ok(ids.includes('someone-elses'), '卸载不该动别人的 Provider');
});
test('模型规则里的 ours 也被摘掉', () => {
const ids = (afterRemove.config.modelConfigRules.providerModelRules ?? []).map((r) => r.providerId);
assert.ok(!ids.some((id) => id.startsWith('opencode-free')), '模型规则里不该残留');
});
console.log('\n[4] 免费通道体门禁与伪装头(打 mock 上游)');
const { server, received } = mock;
const serveConfig = path.join(workDir, 'serve.json');
fs.writeFileSync(serveConfig, JSON.stringify({ schemaVersion: 1, config: {} }, null, 2));
const port = 41987;
const child = spawn(process.execPath, [PROXY, '--config', serveConfig, '--port', String(port)], {
env: { ...process.env, ...baseEnv },
stdio: ['ignore', 'pipe', 'pipe'],
});
// 等代理起来
let up = false;
for (let i = 0; i < 40 && !up; i += 1) {
await new Promise((r) => setTimeout(r, 250));
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(500) });
up = res.ok;
} catch {
/* 还没起来 */
}
}
if (!up) throw new Error('代理未能在预期时间内启动');
try {
await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: 'big-pickle',
messages: [{ role: 'user', content: 'hi' }],
reasoning_effort: 'none',
}),
});
test('请求带上了伪装头', () => {
assert.ok(received.length > 0, 'mock 应收到请求');
const h = received[0].headers;
assert.match(String(h['user-agent']), /^opencode\//, 'user-agent 应自称 opencode CLI');
assert.equal(h['x-opencode-client'], 'cli', '应有 x-opencode-client: cli');
assert.match(String(h['x-opencode-session']), /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/, 'session id 形状应规范');
assert.equal(String(h['x-opencode-session']).slice(13, 16), 'ffe', 'session id 真实倒序时钟末3位必须为 ffe');
assert.match(String(h['x-opencode-request']), /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/, 'request id 应为 msg_ 官方格式');
assert.equal(h['authorization'], 'Bearer public', '匿名通道用 public 占位');
});
test('强制 stream: true', () => {
assert.equal(received[0].body.stream, true, '应强制流式');
});
test('reasoning_effort=none 被摘掉(回归:原样透传会被打成 400)', () => {
assert.equal(
received[0].body.reasoning_effort,
undefined,
'none 不在主动档位里,应当省略该字段',
);
});
test('补齐门禁工具 bash / read', () => {
const names = (received[0].body.tools ?? []).map((t) => t?.function?.name);
assert.ok(names.includes('bash'), '缺 bash 会被免费通道 403');
assert.ok(names.includes('read'), '缺 read 会被免费通道 403');
});
received.length = 0;
await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: 'space-bunny-free',
messages: [{ role: 'user', content: 'hi' }],
reasoning_effort: 'high',
}),
});
test('上游认的主动档位原样透传', () => {
assert.equal(received[0].body.reasoning_effort, 'high', 'high 应当原样透传');
});
// Host 是 fetch 的 forbidden header,改用裸 http.request 才能伪造。
const deniedStatus = await new Promise((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port, path: '/healthz', method: 'GET', headers: { Host: 'evil.example.com' } },
(res) => {
res.resume();
resolve(res.statusCode);
},
);
req.on('error', reject);
req.end();
});
await testAsync('非回环 Host 被拒(403)', async () => {
assert.equal(deniedStatus, 403, '非回环 Host 应 403');
});
} finally {
child.kill();
}
await new Promise((r) => setTimeout(r, 400));
// 钩子相关的测试要写到独立的假 HOME,不能碰用户真实的 ZCode 配置。
console.log('\n[5] 自启钩子的幂等性与死钩子清理(写到临时 HOME)');
const fakeHome = path.join(workDir, 'fakehome');
const hookConfigPath = path.join(fakeHome, '.zcode', 'cli', 'config.json');
const hookEnv = { ...baseEnv, USERPROFILE: fakeHome };
await runProxy(['--install'], hookEnv);
const afterFirst = readJsonSafe(hookConfigPath);
test('装一次就有一条 SessionStart 钩子', () => {
assert.equal(afterFirst.hooks.events.SessionStart.length, 1, '应恰好一条');
});
test('钩子指向本脚本且带 --ensure', () => {
const hook = afterFirst.hooks.events.SessionStart[0].hooks[0];
assert.equal(hook.type, 'process', '应是 process 类型(不过 shell)');
assert.ok(
hook.args.some((a) => String(a).endsWith('zen-proxy.mjs')),
`args 应指向 zen-proxy.mjs,实际 ${JSON.stringify(hook.args)}`,
);
assert.ok(hook.args.includes('--ensure'), '应带 --ensure');
});
await runProxy(['--install'], hookEnv);
test('重复安装不产生第二条(幂等)', () => {
const after = readJsonSafe(hookConfigPath);
assert.equal(after.hooks.events.SessionStart.length, 1, '重复安装后仍应只有一条');
});
// 模拟「早几版装过、文件已被删掉」的历史钩子
const withLegacy = readJsonSafe(hookConfigPath);
withLegacy.hooks.events.SessionStart.push({
hooks: [{ type: 'process', command: 'node', args: [path.join(workDir, 'ensure-proxy.mjs')] }],
});
withLegacy.hooks.events.SessionStart.push({
matcher: 'startup',
hooks: [{ type: 'command', command: 'echo 别人的钩子' }],
});
fs.writeFileSync(hookConfigPath, JSON.stringify(withLegacy, null, 2));
await runProxy(['--install'], hookEnv);
const afterCleanup = readJsonSafe(hookConfigPath);
test('指向已删文件的旧钩子被清掉', () => {
const ids = afterCleanup.hooks.events.SessionStart.flatMap((e) => e.hooks[0].args ?? []);
assert.ok(
!ids.some((a) => String(a).endsWith('ensure-proxy.mjs')),
'废弃的 ensure-proxy.mjs 钩子应被清理',
);
});
test('别人的钩子原样保留', () => {
const kept = afterCleanup.hooks.events.SessionStart.some(
(e) => e.hooks[0].command === 'echo 别人的钩子',
);
assert.ok(kept, '别人的 SessionStart 钩子不该被动');
});
await runProxy(['--uninstall'], hookEnv);
const afterUninstall = readJsonSafe(hookConfigPath);
test('卸载只摘自己那条,别人的留下', () => {
const remaining = afterUninstall.hooks?.events?.SessionStart ?? [];
assert.equal(remaining.length, 1, '应只剩别人的那一条');
assert.equal(remaining[0].hooks[0].command, 'echo 别人的钩子', '留下的应是别人的钩子');
});
} finally {
mock.server.close();
fs.rmSync(workDir, { recursive: true, force: true });
}
console.log(`\n结果:${passed} 通过 / ${failed} 失败`);
if (failed > 0) {
console.log('\n失败详情:');
for (const f of failures) console.log(`\n[${f.name}]\n${f.error.stack}`);
process.exit(1);
}
}
main().catch((error) => {
console.error(`\n自测本身崩了:${error.stack}`);
process.exit(1);
});